diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..cc122c6 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,52 @@ +name: CI + +on: + push: + pull_request: + +permissions: + contents: read + +jobs: + quality: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + submodules: recursive + - uses: astral-sh/setup-uv@v6 + with: + python-version: '3.12' + enable-cache: true + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + cache-dependency-path: web/package-lock.json + - name: Install Python dependencies + run: uv sync --locked --all-groups + - name: Install web dependencies + run: npm --prefix web ci + - name: Verify generated contracts + run: make schemas api-types && git diff --exit-code -- schema web/src/generated && git -C agents/gewu diff --exit-code -- contracts + - name: Verify pack manifest + run: python agents/gewu/scripts/manifest_tool.py verify + - name: Python quality gates + run: make check typecheck boundaries test + - name: Web quality gates + run: make web-check + - name: Build web application + run: npm --prefix web run build + - name: Install browser + run: npm --prefix web exec playwright install --with-deps chromium + - name: Browser tests + run: make browser-test + - name: Check whitespace + run: git diff --check && git -C agents/gewu diff --check + - name: Upload Playwright report + if: failure() + uses: actions/upload-artifact@v4 + with: + name: playwright-report + path: web/playwright-report/ + if-no-files-found: ignore diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1db1391 --- /dev/null +++ b/.gitignore @@ -0,0 +1,16 @@ +.venv/ +.pytest_cache/ +.ruff_cache/ +__pycache__/ +*.py[cod] +.coverage +dist/ +build/ +node_modules/ +web/dist/ +web/test-results/ +web/playwright-report/ +.env +.data/ +/data/ + diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..cd62028 --- /dev/null +++ b/Makefile @@ -0,0 +1,47 @@ +.PHONY: dev check format test typecheck web-check browser-test schemas api-types pack-manifest boundaries benchmark-search + +dev: + GEWU_DATA_ROOT=$${GEWU_DATA_ROOT:-./.data/gewu} ./scripts/python-tool uvicorn gewu.bootstrap.app:create_local_app --factory --reload + +provision-credential: + ./scripts/python-tool python scripts/provision_credential.py $(ARGS) + +seed-test-registry: + ./scripts/python-tool python scripts/seed_test_registry.py $(ARGS) + +check: + ./scripts/python-tool ruff check . + +format: + ./scripts/python-tool ruff format . + +test: + ./scripts/python-tool pytest + +typecheck: + ./scripts/python-tool mypy src + +web-check: + npm --prefix web run check + +browser-test: + npm --prefix web run test:e2e + +schemas: + ./scripts/python-tool python scripts/export_schemas.py + ./scripts/python-tool python scripts/export_catalog.py + ./scripts/python-tool python scripts/export_agent_contract.py + +api-types: schemas + ./scripts/python-tool python scripts/export_openapi.py + npm --prefix web run generate:api-types + +pack-manifest: + python agents/gewu/scripts/manifest_tool.py refresh + +boundaries: + ./scripts/python-tool python scripts/check_boundaries.py + +benchmark-search: + ./scripts/python-tool python scripts/benchmark_search.py $(ARGS) + diff --git a/README.md b/README.md new file mode 100644 index 0000000..0fe47cc --- /dev/null +++ b/README.md @@ -0,0 +1,120 @@ +# GEWU (格物) + +> **Investigate. Connect. Remember.** + +GEWU draws on *géwù zhìzhī* (格物致知) — examining sources, connecting evidence-backed ideas, and preserving reusable knowledge for project teams. It provides a shared research library accessed via a read-only web application, with contributions made exclusively through typed Model Context Protocol (MCP) proposals submitted by local workspace agents. + +--- + +## Architecture & Documentation + +- **Local setup and first use:** [docs/GETTING_STARTED.md](docs/GETTING_STARTED.md) +- **Engineering Handbook:** [docs/README.md](docs/README.md) +- **Product Requirements & Scope:** [docs/PRD.md](docs/PRD.md) and [docs/GOAL.md](docs/GOAL.md) +- **Implementation Plan:** [docs/IMPLEMENTATION_PLAN.md](docs/IMPLEMENTATION_PLAN.md) +- **System Architecture:** [docs/architecture/ARCHITECTURE.md](docs/architecture/ARCHITECTURE.md) +- **Acceptance & Evidence Contract:** [docs/architecture/ACCEPTANCE_CONTRACT.md](docs/architecture/ACCEPTANCE_CONTRACT.md) +- **Local Agent Specification:** [docs/architecture/LOCAL_AGENT.md](docs/architecture/LOCAL_AGENT.md) +- **Local Agent Pack Repository:** [agents/gewu/README.md](agents/gewu/README.md) + +--- + +## GEWU Local Research Agent + +GEWU ships a modular research-agent instruction pack in [`agents/gewu`](agents/gewu/README.md), modeled after the RESEARCH workspace agents (such as Winnow and Praxis). Rather than embedding an unverified agent engine inside the knowledge service, reasoning and tool execution run inside existing local agent hosts: + +- **Target Hosts:** Claude, Codex, Antigravity, and OpenCode. Native discovery and workflows remain unqualified. +- **Role Profile:** [agents/gewu/AGENTS.md](agents/gewu/AGENTS.md) +- **Claude Harness Pointer:** [agents/gewu/CLAUDE.md](agents/gewu/CLAUDE.md) +- **Modular Skills:** + - [`kb-ingest`](agents/gewu/skills/kb-ingest/SKILL.md): Process selected source materials into evidence-bound node proposals. + - [`kb-query`](agents/gewu/skills/kb-query/SKILL.md): Retrieve and explain accepted knowledge with exact citations and scope limits. + - [`kb-curate`](agents/gewu/skills/kb-curate/SKILL.md): Audit freshness, identify duplicates or contradictions, and propose corrections. + - [`kb-synthesize`](agents/gewu/skills/kb-synthesize/SKILL.md): Assemble multi-source comparative syntheses with dependency tracking. +- **Shared Protocols:** [EVIDENCE.md](agents/gewu/protocols/EVIDENCE.md), [SCOPE.md](agents/gewu/protocols/SCOPE.md), [CONTRIBUTIONS.md](agents/gewu/protocols/CONTRIBUTIONS.md). + +--- + +## Setting Up a Project Directory + +GEWU supports two workspace topologies: +1. **Dedicated Research Workspace:** A standalone directory focused entirely on research, ingestion, and knowledge synthesis. +2. **Integrated User Project Directory:** An existing code or engineering project where developers want on-demand access to GEWU research workflows without disturbing their primary development environment. + +### Workspace Directory Layout + +```text +/ + AGENTS.md # Project instructions with GEWU research routing + CLAUDE.md # Claude instructions with GEWU research routing + GEMINI.md # Gemini context with GEWU research routing + .agents/ + gewu/ # Isolated GEWU pack + AGENTS.md # Canonical GEWU research profile + manifest.json # Module registry and checksum verification + protocols/ # Shared evidence, scope and contribution protocols + skills/ # Namespaced kb-* skills + pipelines/ # Pipeline stage procedures + Inbox/ # Raw source documents targeted for ingestion + Drafts/ # Working notes and unaccepted local drafts + Runs/ # Resumption checkpoints and local run receipts +``` + +### Step-by-Step Setup Instructions + +#### 1. Initialize Operational Folders +Create the standard folders for agent resources and local file boundaries: +```bash +mkdir -p /.agents Inbox Drafts Runs +``` + +#### 2. Install the GEWU Agent Pack +Install the pack into its isolated directory and add reversible routing pointers: +```bash +python path/to/GEWU/agents/gewu/scripts/install.py install --mode integrated +``` + +Use `--mode dedicated` for a research-only workspace. Installation refuses an occupied `.agents/gewu` destination. The installer does not edit host MCP configuration or store credentials. + +#### 3. Configure Harness Entry Points + +The installer appends marked routing blocks to root `AGENTS.md`, `CLAUDE.md`, and `GEMINI.md`, preserving existing instruction content. Remove them with `python /scripts/install.py uninstall `; removal stops if a managed block changed. + +#### 4. Configure Local Agent Host (Claude, Codex, Antigravity, OpenCode) + +Configure the selected host's project MCP entry for the authenticated Streamable HTTP endpoint, normally `/mcp`. Supply the bearer token through the host's secure credential or environment mechanism; do not place a live token in checked-in config. Host-specific connection setup is not yet qualified. The local `gewu-connector` executable is an explicit-file upload bridge and does not provide the general GEWU tool catalog. Its upload receipt includes a typed source candidate for `proposal_submit`; selected paths and bytes stay inside the connector. Omitting `source_revision_id` derives a content-bound local revision and checkpoints it for exact retry; callers resuming an existing workflow can pass its original revision ID. + +--- + +## Working with Existing Agents, Skills, and Folders + +When adding GEWU into a user project folder that already has active agents, skills, and configuration folders, GEWU operates with strict boundary isolation: + +### 1. Agent Personas & Profile Routing (`AGENTS.md`, `CLAUDE.md`, `GEMINI.md`) +- **No Overwriting of Root Personas:** An existing `AGENTS.md` or `CLAUDE.md` in the project root defines the user's primary development rules (coding conventions, architecture, testing). GEWU never overwrites this file. +- **Reference Routing:** Add a dedicated section to your existing root `AGENTS.md` or `CLAUDE.md` directing research and knowledge queries to GEWU: + ```markdown + ## Research & Knowledge Base (GEWU) + For knowledge base queries, source ingestion, curation, or synthesis: + - Refer to the GEWU profile in `.agents/gewu/AGENTS.md`. + - Use the `kb-*` skills located in `.agents/gewu/skills/`. + ``` +- **Session Preservation:** The agent remains focused on software engineering by default, transitioning into research mode only when explicit knowledge tasks are requested. + +### 2. Multi-Agent Skill Coexistence & Namespacing +- **Canonical Skill Directory (`.agents/gewu/skills/`):** All GEWU skills are namespaced with the prefix `kb-` (`kb-ingest`, `kb-query`, `kb-curate`, `kb-synthesize`). They remain isolated from existing domain or engineering skills. +- **Host-Specific Skill Locations (`.gemini/skills`, `.claude/skills`, `.opencode/skills`):** If your development environment relies on host-specific skill folders, you can symlink or copy individual `kb-*` skill folders into those locations. Existing skills in those folders are left completely untouched. +- **Self-Contained Instruction Boundaries:** Each `kb-*` skill only references its corresponding pipeline and shared protocols; it does not depend on proprietary host runtime hooks. + +### 3. File System & State Isolation (`Inbox/`, `Drafts/`, `Runs/`) +- **No Untracked Code Changes:** GEWU local skills never touch application source trees, rewrite project files, or make blanket safety git commits. +- **Input Isolation (`Inbox/`):** Ingestion is restricted to documents explicitly placed in `Inbox/` or explicitly passed as file parameters. +- **Draft Isolation (`Drafts/`):** Intermediate reading notes, candidate extractions, and unaccepted drafts remain strictly in `Drafts/`. +- **Checkpoint Isolation (`Runs/`):** Resumption states, step hashes, and local receipts are saved in `Runs/`. +- **Durable Acceptance via MCP:** Knowledge changes become canonical only through typed MCP proposal operations submitted to the GEWU service. The service verifies exact evidence locations and acceptance policies before issuing durable receipts. + +### 4. Tool & MCP Transport Independence +- The installer leaves host MCP configuration unchanged. Add a uniquely named GEWU server using the selected host's project-level configuration and verify that existing server entries remain intact. + +### URL capture scope +The authenticated `source_capture_url` tool blocks all requests unless the service operator sets `GEWU_FETCH_ALLOWED_HOSTS` to exact comma-separated hostnames. It rejects private or reserved DNS answers, nonstandard ports, and redirects to hosts outside that list. Archive support includes UTF-8 text, Markdown, HTML visible-text extraction and PDF text extraction on Linux when bubblewrap, Poppler `pdftotext` and `prlimit` are installed. PDF parsing runs with no network, an empty environment, read-only system mounts and resource limits. Mixed text and blank pages produce a partial extraction; image-only/scanned PDFs report `PDF_NO_TEXT` as unavailable. Encrypted or unsupported PDFs report explicit extraction failure codes. Capture returns a stable source identity, a content-bound revision candidate, and a typed record to submit through `proposal_submit`; failed captures return an unarchived bookmark candidate. Other operating systems do not yet have a qualified PDF sandbox. diff --git a/agents/gewu b/agents/gewu index 254edac..6c96538 160000 --- a/agents/gewu +++ b/agents/gewu @@ -1 +1 @@ -Subproject commit 254edac2c5a34092a7284a971491f59a761c4174 +Subproject commit 6c9653866cef3d659ab1f3577f17487a0ce101a5 diff --git a/docs/GETTING_STARTED.md b/docs/GETTING_STARTED.md new file mode 100644 index 0000000..568977b --- /dev/null +++ b/docs/GETTING_STARTED.md @@ -0,0 +1,182 @@ +# Local setup and first use + +This guide gets GEWU running on your machine so you can inspect the reader and run the current implementation checks while preparing pilot documents. The local project registry is disposable test data. It does not include pilot sources, accepted knowledge, named owners, or production acceptance policy. + +## Requirements + +- Python 3.12–3.14 +- Node.js and npm (the web workspace has a checked-in `package-lock.json`) +- Git +- `uv` is recommended. The repository wrapper can create a virtual environment and install the Python development dependencies with `python3` and `pip` if `uv` is unavailable. + +Run these commands from the repository root. + +## Start a disposable local service + +Set one data directory for every command in this shell. It holds the local Git knowledge repository, artifact files, identity database, and test registry. The default is `./.data/gewu` and should remain outside source control. + +```bash +export GEWU_DATA_ROOT="${GEWU_DATA_ROOT:-./.data/gewu}" +mkdir -p "$GEWU_DATA_ROOT" +``` + +Install the synthetic project registry once: + +```bash +make seed-test-registry +``` + +It creates `dome-performance` and `interactive-video`, plus a shared scope and test-only automatic-acceptance policies. It refuses to replace an existing registry. Only use `make seed-test-registry ARGS=--replace` if you deliberately want to reset that registry; this does not clear the rest of the data directory. + +Start the API in one terminal: + +```bash +make dev +``` + +The first run installs the Python package and development dependencies. The service listens on `http://127.0.0.1:8000`; check `http://127.0.0.1:8000/health` for `{"status":"ok","service":"gewu"}`. Leave this terminal open while using the browser or an MCP client. The local factory keeps persistent state under `GEWU_DATA_ROOT`. + +## Connect the read-only browser + +In a second terminal, keep the same `GEWU_DATA_ROOT` value and provision a viewer credential limited to one fixture project: + +```bash +export GEWU_DATA_ROOT="${GEWU_DATA_ROOT:-./.data/gewu}" +make provision-credential ARGS="--credential-id local-viewer-dome --principal-id local-viewer --role viewer --project dome-performance" +``` + +The command prints the bearer credential once. Copy it into a local password manager or another secure place; do not put it in repository files, shell history, screenshots, or chat. To create access for the other fixture project, run the command again with a distinct credential ID and `--project interactive-video`. + +Install and start the web workspace in another terminal: + +```bash +npm --prefix web ci +npm --prefix web run dev -- --host 127.0.0.1 +``` + +Open the local URL printed by Vite, normally `http://127.0.0.1:5173`. Paste the viewer credential into the connection form. The browser exchanges it for an HttpOnly session cookie; the bearer value is not retained in the page after connection. You can search the selected project, inspect sources and citations when knowledge exists, and disconnect to end the session. + +The synthetic registry is empty, so an empty search is expected at this point. This is useful for checking service startup, credential scope, session handling, and the UI shell; it is not an end-to-end document-ingestion demo or a pilot result. + +## Exercise contribution tools when you are ready + +The API exposes its authenticated Streamable HTTP MCP endpoint at `http://127.0.0.1:8000/mcp`. Provision a separate credential for an agent/editor and only the project it should access: + +```bash +make provision-credential ARGS="--credential-id local-agent-dome --principal-id local-agent --role agent --role editor --project dome-performance" +``` + +Supply the printed token to your MCP host through its protected environment or credential store as `GEWU_TOKEN`. The service URL is `http://127.0.0.1:8000/mcp`. Do not reuse the browser viewer credential. The repository's `gewu-connector` is a selected-file upload bridge, not a general MCP client; see [the local agent specification](architecture/LOCAL_AGENT.md) and [agent pack guide](../agents/gewu/README.md) for its constraints and setup. Native discovery and workflows in Claude, Codex, Antigravity, and OpenCode remain unqualified, so treat any host connection as local experimentation. + +When testing contribution flows, use documents you have permission to process and preserve the exact source version and page/section locations. The fixture's automatic acceptance policy is deliberately test-only. Do not treat an accepted local record as an approved pilot decision. + +## Run implementation checks + +Run the Python checks from the repository root: + +```bash +make test +make check +make typecheck +make boundaries +``` + +Run the frontend checks: + +```bash +make web-check +``` + +Browser tests require the Playwright browser dependencies to be installed for this workspace; then run: + +```bash +make browser-test +``` + +These checks use synthetic fixtures and local test services. They do not need your pilot corpus and do not qualify real-source extraction, semantic answer quality, deployment, or cross-platform behavior. + +## Prepare the pilot materials + +No real materials are needed for the local setup above. Before calling the two-project pilot qualified, the plan still needs: + +- A named first administrator, named owner(s) for each project, and a named shared-library owner. +- Representative source documents for both project domains. Keep original files and identify the edition/version and the locations that support likely claims. NotebookLM exports can be supplied as supporting material, clearly identified as AI-synthesized secondary sources rather than primary evidence. +- A labeled evaluation set of 20–30 questions per project, with expected answer points and the source passages that support them. Include questions whose answer is absent or uncertain so unsupported answers can be measured. +- The intended project boundaries and rules for which records may be accepted automatically and which require owner review. + +Keep the source files and question labels in a separate preparation folder until you decide what to ingest. Do not place them in the synthetic registry or commit confidential material to this repository. When ready, share the agreed corpus, question labels, and owner names so the pilot setup and evaluation can be configured against real data. The current gate status is tracked in the [implementation plan](IMPLEMENTATION_PLAN.md) and [acceptance ledger](acceptance/ledger.md). + +## Reset local test state + +Stop the service with Ctrl+C. To start over, stop all GEWU processes and remove only the disposable data root you configured, then repeat setup. For the default path: + +```bash +rm -rf .data/gewu +``` + +This deletes the local registry, credentials, artifacts, workflow history, and canonical knowledge repository. Do not run it against a data root that contains material you want to keep. + +## Current limitations + +This is a local development service, not a team deployment. Owner-account recovery and first-owner bootstrap are not complete; no real pilot owners are configured. URL capture is disabled unless an operator configures an exact host allowlist. Search uses lexical retrieval by default; optional local embeddings require a separately installed and digest-pinned model. Consult the [operations protocol](development/OPERATIONS_PROTOCOL.md) before considering shared or remote use. + +## Accounts and administration + +Open `/admin` on the web service, or choose **Administration** in the navigation. +Username/password is the default human sign-in method. For first setup, choose +**Use an existing credential** and connect with your operator admin key. In +**User accounts**, create your username and chosen password with role **Administrator**. +Disconnect, then sign in with that username/password. Passwords require at least +12 characters; usernames use lowercase letters, digits, dots, hyphens and underscores. + +If you do not have an operator key, provision one locally using the service's +`GEWU_DATA_ROOT`: + +```bash +scripts/python-tool python scripts/provision_credential.py \ + --credential-id operator-admin --principal-id operator --role admin +``` + +Keep the returned key in a secure credential store. Bearer secrets cannot be retrieved. +After testing your administrator account, you can revoke the old operator key from +that account's admin panel. Retaining an operator key is optional. + +In the admin panel, start with **Projects → Create a project**. Enter its name, +purpose and research questions (one per line). The project ID is generated from the +name and can be adjusted before creation; the owner defaults to your signed-in ID. +The initial policy allows source registration and supported research records that +pass validation. Uncheck automatic contributions to require review instead; owner +review authentication must be configured separately. New projects do not include +other projects by default. The project appears immediately in account and agent +access selectors after creation. + +Administrators create user accounts and assign their role and project access: + +- **Editor**: reads assigned projects and issues contributing agent keys for them. +- **Viewer**: reads assigned projects; cannot issue contributing agent keys. +- **Administrator**: reads all projects and manages accounts and service access. + +Each user opens `/agents` (**Agent credentials**) and signs in to create a key for +each agent or device. Name the agent, choose its projects, and save the displayed +secret. Configure that key as the agent host's GEWU MCP bearer credential. Each key +has a distinct principal ID, visible in the list, for attributed proposals and runs. +Users only see and revoke their own keys, and cannot grant access beyond their own +projects. Administrators can inspect and revoke all keys. Revoke confirmation names +the key; revocation invalidates bearer access immediately. + +**Change your password** appears after username/password login in `/agents` or +`/admin`. Enter the current password and the new password twice. All browser sessions +for that account are invalidated; sign in again. Independent agent keys stay valid. +Disabling a user from the admin panel invalidates their sessions and all their keys. +You cannot disable your own account from its current session. + +**Recent activity** in `/admin` shows the latest 50 account and credential management +actions. It excludes historical CLI actions, runtime logs and agent runs. Account and +key lists are paged. Reloads and navigation restore your existing session automatically +until it expires (eight hours after sign-in), is revoked, or you sign out. Password +changes also end the session. No secrets or CSRF tokens are persisted in browser storage. + +Public password login requires HTTPS. The development proxy preserves the public +Host for the API's same-origin check. Repeated failed logins are temporarily blocked +for 15 minutes. Anonymous registration and email-based password recovery are not +included. Knowledge contributions still go through MCP. diff --git a/docs/IMPLEMENTATION_PLAN.md b/docs/IMPLEMENTATION_PLAN.md new file mode 100644 index 0000000..8d01a28 --- /dev/null +++ b/docs/IMPLEMENTATION_PLAN.md @@ -0,0 +1,173 @@ +# GEWU implementation plan + +13 October 2026 · Planning baseline: [PRD 0.8](PRD.md), [GOAL](GOAL.md), [architecture v0.3](architecture/ARCHITECTURE.md). + +Build a complete local source-to-citation workflow first, evaluate it with the two pilot projects, then expand it to every P0 requirement before full v1. The pilot serves up to five people researching Dome interactive content and an interactive video platform/service. It may use fewer clients, formats and retrieval capabilities than v1; its results must state that coverage explicitly. + +This plan guides implementation; milestone status is recorded below and does not imply a pilot or release gate has passed. The repository also contains an initial agent instruction pack in the `agents/gewu` submodule. Existing pack files need integration work, not recreation. Architecture choices remain proposed until compatibility probes and the affected decisions below are resolved. + +## Implementation status (14 October 2026) + +M1.2/FR-P1 now has an administrator-only project lifecycle control plane. Project IDs are lowercase immutable slugs; creation supplies a named owner and matching initial supported-assessment policy, and commits `projects//project.md` plus `policies/.yaml` in one canonical revision. Archive writes a new revision, preserves reads/history and prevents new acceptance. HTTP routes are explicitly allowlisted outside the MCP agent catalog, enforce admin/non-agent authority, browser CSRF/origin checks and a streamed 64 KiB creation-body cap, and share the canonical writer lock with acceptance. Local contract, authorization, size, persistence and race tests pass. Owner authentication and project grants exist, but named-owner account provisioning, owner reset and first-owner bootstrap remain pilot/deployment gates; protected proposal challenges are implemented separately under M1.1. See [project lifecycle evidence](acceptance/2026-10-14-project-lifecycle.md). + +M1.1 now supports proposal-triggered owner review for policy-only holds. Submission binds a deterministic challenge to the proposal revision, complete before/after canonical files, affected project owners and policy versions. A separate `/owner-mcp` server exposes challenge metadata, the complete bounded diff, and one immutable authorize/reject decision; the owner token remains challenge- and audience-bound. On exact proposal resubmission, current owner grants and decisions are rechecked under the canonical writer lock, every affected domain must be covered, and the acceptance manifest records the decision digests, domains, grant versions, rationale and timestamps. Mandatory validation findings cannot be waived. Unit and MCP integration evidence is in [owner review evidence](acceptance/2026-10-14-owner-review.md). M1.1 remains partial because first-owner bootstrap, reset/recovery, OS-level owner isolation, actual named owners, and independent backup/restore qualification are still external or unimplemented. + +M2.2 is in progress: GEWU pack 0.14.0 includes a copy-based installer with integrated/dedicated routing modes, isolated pack placement, reversible marked pointers for AGENTS.md, CLAUDE.md and GEMINI.md, refusal to overwrite an existing installation, and ownership-checked uninstall that preserves unrelated and later user-added files. Nine isolated filesystem tests pass, including fail-closed rejection of traversal paths, unrecognized routes in edited install state and a symlinked .agents directory. Local run checkpoints pin module hashes and reject resume when a newer verified pack snapshot changes, preserving the checkpoint for explicit revalidation. The installer deliberately leaves MCP host configuration untouched because endpoint and credential provisioning are operator-specific. Native discovery, connection, workflow verification and update/resume behavior in native clients remain NOT RUN; this partial implementation does not close M2.2. + +M2.3 has authenticated `source_capture_url` and `source_capture_text` MCP operations. URL capture is gated by an exact operator-configured host allowlist; the fetch adapter rejects non-public DNS answers, pins connections to validated addresses, revalidates redirects, limits response types, bytes, duration and concurrency, and removes credentials, query strings and fragments from returned URLs. Pasted-text capture accepts UTF-8 plain text or Markdown up to 1 MB, requires a stable capture ID and source key, checks the authenticated project's submission grant, stores bytes in the private content-addressed artifact store, extracts the exact text and returns a proposal-ready source candidate with `capture_method=pasted-text`; the candidate is not registered until a typed proposal is submitted. A private disk journal binds the capture ID to the principal, project and full input digest, returns the original candidate on exact retry, conflicts on changed inputs and is bounded to 1,024 receipts. Canonical source records now distinguish URL, local-file, transcript, declared NotebookLM-export and pasted-text capture; selected VTT/SRT files retain cue timestamps and transcript provenance. URL and selected-file intake accept PDF alongside UTF-8 text/Markdown/HTML. The local connector returns a path-free source metadata candidate, derives stable file identity and content-digest revision IDs when the caller omits one, and checkpoints completed metadata so uncertain retries reproduce the same candidate; transfer state is private and bounded to 1,024 entries. Linux PDF extraction uses Poppler inside bubblewrap with network and host-user files isolated, an empty environment, read-only system mounts, and CPU, memory, time, page and output limits; tests verify page locators and inability to read a host temporary file. Mixed text and blank pages return a partial extraction; image-only/scanned PDFs return an explicit unavailable `PDF_NO_TEXT` result, while malformed, encrypted, oversized and unavailable-parser cases return stable failure codes. URL captures return proposal-ready source metadata candidates with stable sanitized-URL identity and digest-bound revision IDs; failed fetches can be registered as artifact-free `capture-failed` references. Acceptance verifies artifacts only for archived records, and unarchived records remain ineligible as factual evidence. MCP and connector fixture coverage verifies revision changes, proposal registration, authorization, resumable transfer and artifact bytes, including a local archived candidate when PDF extraction is unavailable. A canonical-registry regression test confirms that a replacement parser extraction is added under its new content-addressed ID while the previous extraction remains resolvable for historical citations (`tests/application/test_registry.py`). The local parser now accepts timestamped WebVTT and SubRip cues; proposal validation requires the cited cue times to match the extraction block, and the connector tags those files as transcripts. These synthetic cue fixtures and contracts are covered by tests. Still open: supplied NotebookLM export fixtures, parser support on Windows/macOS, real-source location verification and pilot source-path qualification. + +M2.4 is in progress. Entity aliases are normalized to NFC with collapsed whitespace and checked for empty, repeated and title-duplicate values. Proposal validation compares exact entity names and aliases within the candidate's project and across only the caller-readable projects in the proposal's explicit scope. Scope is enforced both by the authenticated MCP adapter and again in the validator; evaluation does not disclose matches from unreadable or out-of-scope domains. Matches become generic `POSSIBLE_DUPLICATE_ENTITY` review findings and cannot auto-accept. Publication rechecks the credential's role and project-grant snapshot under the canonical writer lock. Aliases are indexed for lexical search. Duplicate review nominates candidates without creating accepted links or merges. Policy-only exceptions now use the protected owner review and manifest binding described under M1.1; destructive merge and reversible merge history remain unimplemented, so merge operations stay unavailable. Existing typed links bind evidence to relationship conditions and rationale. A synthetic contradiction bundle is validated and published atomically with two separately cited claims retained (`tests/domain/test_validation.py`, `tests/application/test_acceptance.py`; [contradiction bundle evidence](acceptance/2026-10-14-contradiction-bundle.md)). The service stores the proposal’s contradiction interpretation; it does not independently infer semantic conflict. Reversible merge history, complete ingest workflow coverage and shared provenance qualification remain open. + +M0.1–M0.7 have a backend foundation and a first protocol-level vertical flow. Present: pinned Python and web workspaces; health path; strict v1 records and generated schemas/catalog; two-project/shared fixtures; resumable private text/Markdown intake; immutable text extraction with normalization maps; deterministic claim and relationship evidence checks; per-domain policy eligibility; private Git publication, manifest/receipt journal and crash reconciliation; and a read-only UI. A synthetic fixture now uploads through MCP, extracts a source, submits and automatically accepts an evidence-backed note, then reads the committed note and exact citation. The same transport flow submits a matching quotation at the wrong location and receives an `invalid` result with `EVIDENCE_MISMATCH`; exact accepted-proposal retry returns the original receipt without a second commit. Artifact-store contract tests verify identical bytes deduplicate while both project grants remain readable and an ungranted project remains denied. The M0.6 and M0.7 publication, recovery and replacement checks now pass, including subprocess crash boundaries, queued-work gating, SQLite generation rollback and the shared repository contract. The complete upload-to-read restart flow and authenticated human browser reading task remain open; the disposable-slice gate is not closed. + +The authenticated canonical-node read is connected to the private Git revision, with project-scoped hashed credentials, revocation, rotation and a one-megabyte response cap. Streamable HTTP MCP exposes the read and contribution workflow, including non-publishing `proposal_evaluate`, author-scoped `proposal_status`, writer-locked `proposal_withdraw` and authenticated secret-free `diagnostics` over HTTP/MCP. Diagnostics binds the running catalog hash, transport- and role-authorized operations, explicit selected scope, transfer limits and canonical/index revision state; it labels connection as authenticated while leaving workflow verification separate. Agent bearer clients also have `run_create`, revision-checked/idempotent `run_checkpoint` and owner-scoped `run_status`, backed by private atomic files and the canonical writer lock. Run tests cover idempotent create, changed-input conflict, checkpoint retry and stale revisions, owner isolation, credential revocation and closed runs; the live MCP test exercises create/checkpoint/status. Evaluation tests cover valid and invalid proposals and prove evaluation does not advance canonical state or persist status. Withdrawal tests verify accepted work cannot be withdrawn, withdrawal is idempotent, a withdrawn proposal cannot later be submitted, and other agents cannot evaluate, inspect or withdraw it. Recovery tests cover status after ref-before-receipt and prepublication restart; subprocess termination after ref update and before receipt write reconstructs the original receipt after fresh service construction. An unrelated proposal is held until pending reconciliation succeeds; a killed SQLite generation rebuild retains the prior complete index and later serves the new canonical revision. The shared repository contract passes against Git and an in-memory substitute, with the canonical node reader unchanged. Full upload-to-read process restart and target-platform filesystem qualification remain open. Proposal authorship is bound to the authenticated agent; every affected project, including shared policy, requires a grant; the current credential is rechecked while holding the same lock as publication and revocation. Upload sessions, staged extractions and artifact access are principal-scoped, and artifact reads also verify project provenance. A local test-registry seed command is explicit and refuses replacement unless requested. The `gewu-connector` stdio bridge exposes only explicitly allowlisted local files, streams their bytes internally, checkpoints upload IDs privately, resumes from server-verified offsets and keeps bytes and the bearer token out of tool results. Its restart simulation uses a mock MCP service; an additional test connects it to the real local GEWU MCP endpoint. Authenticated HTTP readers can now download an immutable archived source revision only when the canonical record grants it to the requested project; responses stream verified bytes with a digest ETag, private caching, MIME sniffing protection and sandbox CSP. Native-host installation, complete process-kill qualification, public remote TLS service qualification and client workflows remain open. M1.4 now has a preliminary rebuildable SQLite FTS5 index, authenticated scoped lexical search with query/scope/index-revision-bound continuation cursors, typed-edge neighbor reads, source metadata reads and bounded node history over HTTP/MCP. Reads disclose canonical/index revisions, preserve exact citations, apply freshness warnings and budget complete responses. Search pagination retains unreturned results when response budgeting trims a page and returns a conflict when the indexed snapshot changes. Neighbor source metadata and standalone source reads require the source ingested_for grant to intersect the authorized project; node history reads validated versions from the canonical Git path. Nodes, edges, source metadata and normalized source-grant rows rebuild transactionally under SQLite schema version 4. Neighbor pagination also binds query/scope/page size/index revision, filters unauthorized endpoints in SQL before applying offsets, and expires with 409 when the indexed snapshot changes. The authenticated API returns bounded original and normalized extraction slices with source offset maps, and the web reader opens those cited slices and links the archived revision. Frontend tests cover the interaction; viewport/accessibility qualification remains open. Synthetic SQLite adapter benchmarks now report scoped warm-search p95 below 300 ms at both 10k and 100k total nodes, and index rebuild below ten minutes at 100k; real Git I/O, end-to-end service overhead, target hardware and workload, and retrieval quality remain unqualified. Language evaluation and full performance acceptance remain open. The codepoint counting profile is provisional and is not a model tokenizer. A cross-service writer-lock test now proves a concurrent publication receives `BUSY`; a comprehensive second-writer and shared-replacement contract suite remains open. The 20 real representative sources and labeled questions have not been supplied. The web reader now connects with an in-memory viewer credential, discovers authorized projects, performs URL-scoped lexical search, opens accepted notes with exact citations, source metadata and node history, and exposes no knowledge-write controls. Human browser sessions now exchange a human-only bearer credential for a short-lived HttpOnly, SameSite=Strict cookie with a double-submit CSRF token; agent credentials cannot create sessions, and browser-session principals cannot submit agent proposals. The policy-exception owner flow is implemented; named pilot ownership and protected merge authorization remain open. The T3 preview loaded the development page, but snapshot automation failed repeatedly; visual protocol screenshots, viewport/zoom checks and keyboard/screen-reader review remain open. Python 3.14 is exercised; Python 3.12 and target operating systems are not qualified. No real ownership or production acceptance policy is configured. + +M3.1–M3.2 have a working read-only search and citation reader. The current interface selects an authorized project, performs scoped lexical search, opens canonical notes, displays exact extracted citations, source revisions, history, typed relationship conditions and rationale, and the recorded claim assessment method/status/rationale. It keeps accepted relationships labeled separately from on-demand lexical related suggestions and exposes no knowledge mutation controls. Search includes authorized server-side node-type, past-freshness, confidence, as-of date range and exact tag/audience/use/domain filters; project, type, freshness, confidence, dates, query, facets and selected-node/revision values are reflected in the URL. Direct query/filter URLs restore after authentication, and browser back/forward restores previous searches and reruns them within the selected scope. Editing the search form creates one draft history entry, subsequent edits replace it, and submission does not create duplicate entries. Opening an item records its node ID and owning project; a shared current-node link restores current accepted state after authentication, and a revision selected from history is pinned in the URL and restored by browser back/forward. The facet groups apply before pagination, and continuation cursors bind their values. An empty query is accepted only with a knowledge-type filter, enabling a bounded “Browse problems” view while rejecting untyped browse requests. The typed browse path uses the same authorized scope, budget and cursor contract as lexical search. A paginated source browser now lists indexed records only when their provenance grants intersect the caller's selected project/shared scope, with archive, reliability, publisher, license, revision and authorized download details. Stale knowledge has a direct navigation shortcut into the URL-backed freshness filter; Problems and Topics shortcuts select the corresponding URL-backed node-type filters. Relationship panels state that excluded-project names and counts are not disclosed. Problem detail now labels accepted links as approaches and related knowledge, distinguishes the absence of visible accepted approaches from suggestions, and displays recorded tag/audience/use/domain facets. Knowledge nodes now support explicit assumption and limitation claim groups; each statement uses the same exact evidence, assessment and citation requirements as an ordinary claim, with a distinct assertion path and unique claim ID. Search, canonical reads, history and the reader carry those citations through, and the UI labels the groups separately. Revisions exposed by the bounded history response can be selected through an authorized revision-scoped read; pinned views omit current-index relationships and explain this limit. M3.3 remains partial: cross-project relationships are safely omitted rather than explained individually, and visual/accessibility acceptance is open. Frontend tests cover shared query and selected-node/revision URL restoration, search and node back/forward, and synthetic source-list, facets, problem detail, typed assumption/limitation rendering and citation paths plus distinct initial, empty-result and request-failure states. At 320×800, T3 preview evaluation of the disconnected screen confirms no horizontal document overflow; the navigation strip scrolls horizontally within its own row. T3 snapshot automation fails, so the authenticated view has no screenshot, 200% zoom, screen-reader, axe or real two-minute reading-task evidence. + +M0.1 foundation verification now includes a two-test Playwright Chromium suite and a GitHub Actions workflow for push/pull-request quality gates. The suite exercises authenticated read-only UI behavior against a routed synthetic API and checks the narrow-screen citation reader; it is not live-backend or accessibility acceptance. M0.1's local acceptance criteria pass. The hosted workflow and its Python 3.12/Node 22 matrix have not yet been observed. M0.2 now exports the composed HTTP API's OpenAPI schema, includes canonical record schemas, generates TypeScript API types, and checks actual HTTP route IDs against the explicit HTTP operation allowlist; the reader uses generated request/response types. The full PRD contract audit and final budget-counting profile remain open. M0.3's two-project/shared fixture now checks that every affected project's auto-accept policy permits a source shared across project grants; a restrictive policy blocks automatic acceptance. Wrong-location, unsupported-assessment, stale-assessment, missing-policy and scope-escape cases have explicit assertions. Real pilot sources and labeled expected-answer questions remain missing. M0.4's local upload/extraction checks now explicitly reject bad chunk and artifact digests, prove interrupted data cannot be extracted, and retain byte deduplication grants and location maps; the authenticated MCP slice proves the same protocol boundary. Cross-platform and real-source format qualification remain open. M0.5 now rejects policies that attempt to waive required support assessment; tests also prove stale assessments cannot be reused, invalid submissions remain staged, and routine fixture work publishes under policy. Its local acceptance checks pass; semantic assessor quality remains a separate pilot gate. + +M1.1 now has actual MCP denial coverage for viewer proposal/upload calls and a publication-time SQLite revocation test that stops a key revoked during validation before any canonical commit. Owner account provisioning, Argon2id password storage, encrypted TOTP seeds, replay protection, account-level failure throttling, explicit grants, revocation, challenge-bound five-minute sessions and the HTTPS/loopback-only authentication endpoint are implemented. Policy-only proposal holds now create exact-diff owner challenges; the isolated owner MCP can review and decide, and authorized resubmission records decision evidence in the acceptance manifest. Reset/recovery, OS-level owner isolation, first-owner bootstrap and named pilot owners remain open. No owner accounts are provisioned, so M1.1 stays partial. + +### M2.1 — local GEWU pack binding (partial) + +Pack version 0.14.0 now pins an MCP contract snapshot generated from `MCPServer.list_tools()`, plus the generated operation catalog and record schemas. It includes bounded authenticated pasted-text capture, transcript cue locators and source capture-method metadata. Optional `include_projects` is exposed in HTTP and MCP search scope; end-to-end tests prove omitted includes resolve project defaults, explicit empty suppresses them, authorization covers expanded scope, and direct node/source reads plus neighbor and source-list results obey scope. The pack contract snapshot is regenerated for these additive request and record fields. A contract test rebuilds a temporary service and compares discovered tool input schemas against the bundled snapshot. The pack includes a hash/snapshot verifier and local atomic checkpoint tool recording run identity, scope, lens, coverage reasons, pending operation digests, proposal revisions, receipts and output hashes; resume rejects module drift. The service now exposes private agent-owned run create/checkpoint/status operations, and the contribution protocol describes their revision/idempotency recovery contract. Proposal validation rejects node or assessment attribution that differs from the authenticated proposer. These checks cover local contract consistency and synthetic behavior only. Codex/Claude/Antigravity/OpenCode installations, real pilot workflows, native host resume and OS matrix evidence remain NOT RUN; MCP now exposes the project lens to principals with project read permission, without exposing the autonomy policy. + +## Delivery boundaries + +| Delivery | Included | Completion gate | +| --- | --- | --- | +| Disposable local slice | Two fixture projects plus shared; one client; text/Markdown; test policy; source upload, note proposal, exact evidence/support checks, automatic acceptance, canonical read and browser citation | One complete valid flow, an invalid-location rejection, shared-source reuse, exact retry and restart recovery. No production-data or policy claim. | +| Evaluated pilot | Selected real sources/client/format; actual roles and separate agent identities; owner-approved policy; installed GEWU pack; scoped search and problem/source reading; quality sampling | Useful cited answers with agents offline, recorded semantic quality, enforced contribution boundaries and all pilot gates passed. | +| Full v1 | Every PRD P0 feature, hybrid retrieval, required inputs, four harnesses, declared Windows/macOS/Linux coverage, offline operation, protected decisions, recovery and local-to-VPS handover | Complete P0 and non-functional evidence, independent restore, measured capacity/retrieval and native-client workflows on the target deployment. | +| Follow-up | Selected M4/M5 P1 features | Separate feature gates; these do not delay P0 scope, source retention, authority or recovery. | + +Use the proposed Python/FastAPI/Pydantic service, Git canonical records, private artifacts, derived SQLite index, stdio MCP connector and React/TypeScript reading app. Resolve and lock dependencies during scaffolding. Evaluate the embedding model and vector adapter before treating their candidates as committed choices. No new stack research or package-version claims are part of this plan. + +## Decisions and readiness + +Record outcomes in task notes or an ADR when they change a contract, authority boundary, persistence or deployment. A pending production decision does not prevent isolated fixture work; it does prevent claiming the affected pilot/release gate passed. + +| Decision | Proposed starting point / required input | Needed before | +| --- | --- | --- | +| Pilot client and platform | Use Codex on the available Linux development host for disposable tests. Owner selects the real pilot pair; Windows/Codex remains an architecture recommendation. | Native pilot acceptance | +| Pilot content | Text/Markdown for the disposable slice; collect 20 representative sources across both domains and labeled questions. Finalize project IDs; `dome-performance` and `interactive-video` are suggestions. Include the deep-research report when supplied, labeled as an AI-synthesized secondary source. | Real ingestion and quality evaluation | +| Ownership and bootstrap | Name the first admin, project owners and shared-library owner. Use separate operator bootstrap and human owner MCP authorization. | Pilot account/policy provisioning | +| Automatic acceptance | Fixture policy initially permits additive shared sources and project factual notes with valid evidence/support. Owner must enumerate production rules for sources, notes, datapoints, entities, edges and interpretations in each affected domain. Missing rules hold changes. | Pilot automatic acceptance | +| Bundle and read behavior | Adopt the acceptance-contract recommendation: atomic bundles under all affected policies, conservative base-commit conflicts, and canonical accepted-revision reads during index lag. | M0 persistence contract | +| Quality sampling | Name reviewer, sample size/cadence, semantic-error limits and correction/escalation response. Same-agent extraction/assessment is already permitted for routine notes. | Pilot evaluation | +| Retrieval profile | Evaluate local embeddings on the labeled languages/corpus; fix tokenizer, vector packaging, graph/candidate caps and index generation metadata. | M1 hybrid acceptance | +| Operating envelope | Specify concurrent users/agents, upload/archive limits, retention/rights, VPS resources, backup owner, recovery-point and recovery-time objectives, benchmark hardware and percentile. | Hosted pilot and full-v1 measurements | +| Intake and release coverage | Define the initial NotebookLM export path and supported representations, exact OS/harness pairs, and any P1 launch requirements. Start with manual supplied exports unless another path is selected. | Format/client qualification and release scope | + +## Execution order + +Keep the PRD's M0–M5 names. Deliver the disposable flow and pilot through a narrow pass across M0–M3, then broaden M1–M3 for v1. Each row below is a bounded work package; split it into reviewable changes around observable behaviors rather than implementing an entire layer at once. + +### M0 — Contracts, fixtures and acceptance foundation + +| Task | Owning module and deliverable | Depends on | Acceptance evidence | +| --- | --- | --- | --- | +| M0.1 Scaffold a runnable foundation | Bootstrap/development: Python package and web workspace, lockfiles, isolated data-root configuration, CI scripts and one health path. Follow the architecture's proposed layout; instantiate only needed modules. | None | Recorded runtime versions; backend lint/type/test and frontend lint/type/unit/browser scripts run; import-boundary checks exclude transport/provider imports from domain code. | +| M0.2 Publish shared contracts | Domain/catalog: strict versioned records, node/edge schemas, project registry/lenses, scope, proposal lifecycle, errors, upload/run limits and whole-response budgets. Generate JSON schemas, read API types and explicit HTTP/MCP allowlists. | M0.1; bundle/read decision | Valid/invalid golden fixtures; unknown fields/unsafe IDs rejected; no mutation or owner operation generated for the read API. | +| M0.3 Create acceptance fixtures | Domain/evidence: two projects plus shared, sources and expected claims/relationships, valid and adversarial locators, labeled unsupported assertions and stale dependencies. Keep illustrative PRD examples separate from executable fixtures. | M0.2 | Wrong-location quote, correct quote with unsupported assertion, stale assessment, missing policy, cross-policy bundle and scope-escape cases have explicit expected outcomes. Semantic cases evaluate assessor quality separately from deterministic checks. | +| M0.4 Implement immutable intake | Artifact/extraction stores: resumable begin/chunk/finalize, source manifests, hash dedupe without lost provenance, text/Markdown location maps, safe serialization and immutable extraction artifacts. | M0.2–M0.3 | Matching chunk retries succeed; changed chunk/incorrect hash fail; interrupted uploads are uncitable; two projects reuse one artifact; unavailable extraction remains explicit. | +| M0.5 Implement proposal validation | Validator/policy: schema, scope, links, quote-at-location, assertion-bound assessments, findings, policy-domain evaluation and revision invalidation. | M0.3–M0.4 | Mandatory checks cannot be waived by policy; changed assertions cannot reuse assessments; failing factual claims remain staged; routine fixture proposals become eligible without human approval. | +| M0.6 Implement durable publication | Repository/workflow: staged revisions, journal, exclusive writer, final authority/base/policy rechecks, private Git index, expected-head publication, committed acceptance manifest and immutable receipt reconciliation. | M0.5; identity port | One commit per accepted revision; changed-payload retry conflicts; concurrent writes serialize; unrelated files are excluded; committed-tree reads work before indexing. | +| M0.7 Prove recovery and replacement boundaries | Recovery/module contracts: inject crashes/write failures around proposal, artifact, ref, receipt and index boundaries; block new publication while reconciliation is incomplete. | M0.6 | Ref-before-receipt retry reconstructs the original receipt without another commit; live retries and unrelated queued work respect the recovery gate; a second writer is excluded. Subprocess termination immediately after ref update and before receipt write survives fresh repository/workflow adapter construction; an empty derived index rebuilds from the recovered canonical commit and returns its citations. The shared repository contract passes against Git and an independent in-memory adapter, and the node reader works against the substitute unchanged. A bad pending-manifest digest blocks an unrelated proposal before it is staged; after reconciliation is repaired, the original receipt is recovered and the queued proposal succeeds. The index transaction is killed during rebuild over an existing generation; SQLite retains that generation, then a normal search rebuilds and serves the complete current canonical set. | + +M0 exits with executable contracts and a disposable accepted source/note that can be read at its committed revision. Real-source semantic evaluation and all 20-source M0 fixtures remain visible until supplied and checked; synthetic fixtures alone cannot close that PRD requirement. + +### M1 — Authenticated service, MCP and retrieval + +| Task | Owning module and deliverable | Depends on | Acceptance evidence | +| --- | --- | --- | --- | +| M1.1 Establish identities and authority | Identity/control: admin/editor/viewer, stable agents and hashed revocable keys, owner grants, browser sessions, restricted bootstrap and separate owner client/challenges. | M0.2; ownership decisions for pilot | Viewer proposals denied; browser admin still cannot author; rotation retains attribution; revoked key fails at publication; agent cannot impersonate assessor/owner. Protected authorization is digest-bound and expires; replay/change/grant-revocation and recovery cases pass. | +| M1.2 Expose one application core | MCP/read HTTP: catalog-backed reads, upload/proposal/status/revision/withdraw/evaluate and run operations, bounded errors/diagnostics. | M0.4–M0.7, M1.1 | Actual transports enforce capabilities and scope; no generic HTTP/CLI knowledge writes or raw queries; accepted reads disclose canonical/index revisions; diagnostics contain no secrets. | +| M1.3 Bridge selected local files | Connector: stdio MCP to service, explicit-file byte transfer, bounded upload resume, service identity checks and protocol-only stdout. | M1.2 | Selected file reaches service by bytes/hash, including a remote service; arbitrary VPS-local path interpretation fails; byte payloads and credentials stay outside model-visible responses. | +| M1.4 Deliver scoped lexical reading | Index/query: FTS, typed edges, project/shared filters and `default_includes` resolution, canonical revision reads, neighbors/history, read-time freshness and synthesis dependency warnings, complete citation groups and tokenizer accounting. | M0.7, M1.2 | IDs/redirects/graph/citations obey scope; omitted project includes resolve defaults plus shared for one project context, explicit empty includes suppress defaults, and every expanded project is authorized; stale warnings survive budgets; too-small budget returns a defined error; index deletion/rebuild preserves authority and citations; index lag is disclosed. | +| M1.5 Qualify hybrid retrieval | Embedding/vector/ranking adapters: model/native-package probes, scoped keyword/vector fusion, capped graph expansion, language fixtures and isolated index generations. | M1.4; retrieval/benchmark decisions | Top-five source recall meets the PRD target on labeled questions; old citations survive model/parser changes; offline assets work; keyword degradation is labeled. Real provider swap and generation rollback pass consumer contracts without search-consumer changes. | + +M1.1–M1.4 support the pilot. M1.5 and all four native client flows are required to close full M1/v1. Authentication and enforced data isolation precede any non-disposable shared use. + +### M2 — Local agent integration and input coverage + +| Task | Owning module and deliverable | Depends on | Acceptance evidence | +| --- | --- | --- | --- | +| M2.1 Bind the existing GEWU pack | Agent profile/skills/pipelines: use real tool/schema contracts, pin pack/module hashes, record scope/lens, source coverage, skips, proposal IDs and receipts in durable runs. Update the separate pack submodule deliberately. | M1.2–M1.4 | Installed pack completes ingest/query, attributes same-agent assessments, reports unavailable P1 capabilities, and resumes an uncertain outcome through service status without regeneration or duplicate acceptance. | +| M2.2 Build additive installation | Harness adapters: preserve existing personas, skills and MCP entries; dedicated/integrated workspace routing; thin discovery pointers including needed GEMINI routing; copy fallback and owned-entry rollback. | M2.1, M1.3 | Install/uninstall leaves unrelated configuration intact; discovery, connection and workflow verification recorded separately; newer pack installation cannot silently alter a pinned active run. | +| M2.3 Expand source intake | Fetch/parser adapters: URL/HTML, PDF, supported local files, pasted text and supplied NotebookLM exports; transcript locators where applicable; archived downloads, source identity/revision changes, reference-only failure records. | M0.4, M1.3; format limits | Exact locations verified on real fixtures; changed URL creates a revision; failed capture preserves prior bytes; scanned/encrypted/unsupported material returns honest status. Parser replacement retains old extraction resolution. Required input paths are qualified; unsupported document subtypes are listed explicitly. | +| M2.4 Complete linking and consistency | Entity/relationship application services plus ingest skills: canonical aliases, duplicate candidates, reversible merge history, evidence/rationale-bound typed links, contradictions and supersession. | M0.5–M0.6, M2.1 | Shared dedupe preserves provenance; substantive links include conditions/support; similarity does not create accepted edges; destructive merges require authority; contradictions retain both claims. | +| M2.5 Qualify native hosts and quality | Acceptance/client fixtures: Codex, Claude, Antigravity and OpenCode on declared supported OS pairs; five common documents through two agents; sampling and labeled coverage/error evaluation. | M2.1–M2.4; quality/coverage decisions | Each supported pair transfers, rejects invalid evidence, auto-accepts eligible work, retries/resumes and retrieves citations. Convergence compares findings/support errors; no pass based on matching prose or tool listing. | + +For the pilot, complete M2.1–M2.2 and the selected-format/client portions of M2.3–M2.5. Remaining matrix cells and source paths stay NOT RUN or BLOCKED until verified; they remain full-v1 obligations. + +### M3 — Read-only application and pilot evaluation + +| Task | Owning module and deliverable | Depends on | Acceptance evidence | +| --- | --- | --- | --- | +| M3.1 Build reading primitives | Design system/app shell: editorial tokens, typography, scope selector, result/evidence/relationship rows, freshness and system states; generated read-client types. | M0.2, M1.2; UI fixtures may start earlier | Real rendered review under the [visual protocol](development/VISUAL_DESIGN_PROTOCOL.md); keyboard navigation and error/empty/stale/index-lag states; semantic tokens replace feature-local styling. | +| M3.2 Connect search and citations | Search/node/source/history features: URL-owned scope/filters/query/selected node/revision, archived revision download/location preview, typed neighbors and citing notes. | M1.4, M3.1 | Scope → search → note → quote → exact archived revision works with agents stopped; direct query/filter URLs restore after authentication, selected node and history revision deep links and browser back/forward restore the reader; typing query/facets does not submit until Search; obsolete responses do not replace current scope; logout clears private caches; safe Markdown/preview rendering. History is bounded to the latest 20 revisions; real two-minute tasks and visual/accessibility qualification remain open. | +| M3.3 Deliver problem/topic reading | Problem/topic/staleness features: approaches, assumptions, cases, source references, facets and separately labeled related suggestions. | M2.4, M3.2 | A viewer explores multiple approaches and their conditions; excluded projects remain explicit; no capture/edit/accept/generate controls or hidden mutation client. Search supports URL-owned exact tag/audience/use/domain facets, applied in SQLite before pagination and bound into cursors. “Browse problems” lists scoped problem records without a text query, with type-bound pagination; cross-project explanations and visual/accessibility acceptance remain open. Evidence panel replacement preserves public API/navigation. | +| M3.4 Evaluate the pilot | Acceptance/quality: real two-project corpus, shared-source reuse, viewer/editor/admin and two agent identities, retry/restart, quality sampling and timed reading tasks. | Pilot portions of M0–M3; production pilot decisions | Person finds a cited answer within two minutes; location validity and assessment coverage meet PRD requirements; semantic errors and reviewer effort recorded against agreed limits. All missing full-v1 features remain listed. | + +Inspect the primary path at the visual protocol's desktop/mobile sizes, 200% zoom and 320-pixel reflow. Combine automated accessibility checks with keyboard and screen-reader evidence. The pilot requires enough real UI to test research usefulness; a service-only demonstration cannot close M3.4. + +### Full-v1 hardening and local-to-VPS release + +This gate spans M0–M3; it is not a new product milestone. Finish all P0 coverage before labeling a release v1. + +| Task | Deliverable | Required evidence | +| --- | --- | --- | +| R1 Enforce deployment boundaries | Restricted service identity, private data/credential mounts, parser sandbox/resource limits, bounded queues/uploads, authenticated artifacts, URL/DNS/redirect restrictions and MCP admission controls. | Traversal/symlink escape, malicious source instructions, secret-read, private-network fetch, unsafe preview and denied-capability cases fail through actual boundaries. Separate-process existence alone does not prove sandboxing. | +| R2 Exercise offline and portable operation | Native Windows/macOS/Linux paths, safe storage keys, non-symlink installation, local model/tokenizer/font assets and parser/vector packaging. | Existing-source validation, ingestion, index/rebuild and reading work offline; configured local-model workflow demonstrated where claimed; new external fetching reports unavailable. Declared host/OS matrix passes. | +| R3 Measure product and capacity targets | Fixed workload/corpus/hardware, latency percentiles, retrieval quality, queue saturation and cold/warm rebuild profiles. | PRD search target under 300 ms at 10,000 project nodes; 100,000-node scale and under-ten-minute rebuild evaluated with stated cache assumptions; ≥80% top-five source recall on 20–30 questions per project; acceptance rate tracked toward ≥70% after tuning. Exceptions remain visible, not redefined as passes. | +| R4 Prove backup, restore and migrations | Consistent canonical/artifact/workflow/control backups; protected owner-key recovery; schema/index migrations and rollback procedures. | Independent restore verifies hashes, grants, manifests, runs, receipts and citation reads, then disposable acceptance. Index deletion loses no authority. Revoked access is not revived; owner-key recovery and missing-key reset paths tested; recovery objectives measured. | +| R5 Transfer authority and qualify VPS | Locked release images, Compose/Caddy, private volumes, freeze/transfer/verify local state, rebuild, client endpoint switch and controlled rollback/handback. | One writer only; authenticated HTTPS/browser reading and native remote upload/accept/retry/resume/retrieval evidence; restored test copy cannot become a second authority; local and deployed revisions recorded separately. | +| R6 Close release coverage | P0 traceability and acceptance ledger, runbooks, limitations and first-month reuse follow-up. | No required gate is FAIL, BLOCKED or NOT RUN. Record shared-node reuse during the first month rather than claiming it passed on release day. | + +### M4 and M5 — Selected follow-up + +M4 adds P1 analysis/task briefs, synthesis compilation/refresh, deterministic evidence bundles or saved briefs, curator reports, audience lenses, matrices, comparisons, timelines and graph exploration. Decide brief semantics and release priority before implementation. Existing P0 synthesis dependency warnings and source/relationship reading are already delivered in M1/M3. + +M5 adds P1 shared promotion and project export/import with redirects, provenance, policy-domain authority and durable receipts. Imports enter through MCP proposals. Exercise three projects for this later milestone; the confirmed pilot has two. P0 cross-project scope and dedupe are already enforced in M0–M2. Keep project confidentiality, automatic hooks, clustering and slide export under their existing P2 priorities. + +## P0 traceability + +This maps implementation ownership; the release ledger must still record each requirement separately, including the PRD's non-tabular evidence, lifecycle and source-retention rules. + +| PRD requirement | Work packages | +| --- | --- | +| FR-P1, FR-P2, FR-P5 | M0.2–M0.3, M1.2, M1.4, M2.1, M3.2 | +| FR-P3 | M0.4, M2.4 | +| FR-P8 | M1.1–M1.2, M0.6–M0.7, R1/R4 | +| FR-I1, FR-I2, FR-I9 | M0.4, M1.3, M2.3 | +| FR-I3, FR-I4, FR-I7 | M0.3–M0.5, M2.1, M2.5 | +| FR-I6 | M2.4 | +| FR-I8, FR-I11 | M0.6–M0.7, M1.2, M2.1–M2.2 | +| FR-V1, FR-V2, FR-V3 | M0.5, M1.1–M1.2, M3.4 | +| FR-V4 | M2.4, M1.4, M3.2 | +| FR-V5 | M0.6–M0.7, M1.1–M1.2 | +| FR-A1, FR-A9, FR-A10 | M0.2, M1.2–M1.4, M2.2, M2.5 | +| FR-A2 | M1.5 | +| FR-A3 | M0.2, M1.4–M1.5 | +| FR-A5 | M0.6, M1.1–M1.2, R1/R5 | +| FR-A6, FR-A11 | M2.1–M2.2, M2.5 | +| FR-C3; displayed synthesis warnings (§6.3.2) | M1.4, M3.2–M3.3 | +| FR-U1, FR-U2, FR-U3, FR-U6, FR-U9 | M3.1–M3.4 | +| Non-functional requirements and success metrics | M0.1–M0.7, M2.5, M3.4, R1–R6 | + +## Task completion and evidence + +Each implementation task records: task ID, observable outcome, requirement IDs, owning module/public contract, dependencies, failure cases, state migration/rollback where applicable, checks and evidence. Apply the [development protocol](development/DEVELOPMENT_PROTOCOL.md) and [modularity protocol](development/MODULARITY_PROTOCOL.md); do not duplicate their contracts in every task. + +The [acceptance ledger](acceptance/ledger.md) tracks each M0–M3 and R1–R6 gate, its current status, evidence scope and missing proof. Detailed fixture and command results remain in dated acceptance records. Each gate must distinguish structural pack validation, installed discovery, connection, native workflow and semantic quality; no fixture-only evidence closes a pilot or release gate. + +Dependencies determine sequence, not fixed calendar dates. Staffing and source/decision availability are unknown; estimate effort after M0 contracts and the first vertical flow expose parser, client and recovery costs. Review progress at the disposable-flow, pilot-evaluation and full-v1 gates, with unresolved decisions and failed evidence visible. + +## First implementation batch + +1. Complete M0.1 with isolated temporary data roots, actual verification scripts and initial import-boundary checks. +2. Implement M0.2–M0.3 for a shared text source and one project factual note, plus wrong-location, stale-assessment and scope fixtures for both projects. +3. Implement M0.4–M0.7 for that bundle under an explicitly named test policy; prove exact retry and ref-before-receipt recovery. +4. Add the minimal M1 identity/MCP/connector/read path, bind `kb-ingest`/`kb-query`, and connect a minimal M3 citation view. +5. Demonstrate the disposable source-to-citation flow, then finalize real pilot inputs/policy and expand the same contracts. Do not scaffold P1 workflows before this path works. diff --git a/docs/README.md b/docs/README.md index 1ee5ef0..5b51e0f 100644 --- a/docs/README.md +++ b/docs/README.md @@ -1,6 +1,6 @@ # GEWU architecture and engineering handbook -Started 12 October 2026; revised 13 October 2026. Architecture proposal v0.3. The initial local-agent instruction pack exists; service, connector and UI implementation has not started. +Started 12 October 2026; revised 14 October 2026. Architecture proposal v0.3. The backend, authenticated HTTP/MCP workflows, a read-only research UI, and a modular GEWU agent pack are implemented to varying levels of local verification. Consult the [implementation status](IMPLEMENTATION_PLAN.md#implementation-status-14-october-2026) and [acceptance ledger](acceptance/ledger.md) for evidence and open gates. **Tagline:** Investigate. Connect. Remember. @@ -8,6 +8,10 @@ GEWU (格物) draws on *géwù zhìzhī* (格物致知), a classical phrase comm ## Read in this order +For a local setup and first browser session, start with the [getting started guide](GETTING_STARTED.md). It uses a disposable synthetic registry and does not claim pilot qualification. + +Start delivery planning with the [implementation plan](IMPLEMENTATION_PLAN.md): work packages, pilot/full-v1 boundaries, decision gates and PRD traceability. + 1. [Reference review](reviews/ARCHITECTURE_REFERENCE_REVIEW.md): which practices to borrow, adapt or reject. 2. [Architecture](architecture/ARCHITECTURE.md): components, technology choices, alternatives and deployment. 3. [Acceptance and evidence contract](architecture/ACCEPTANCE_CONTRACT.md): records, authority, replay, crash recovery and source integrity. @@ -34,8 +38,8 @@ Protocol language describes the intended implementation standard if this baselin ## Next implementation boundary -Start with a disposable local vertical slice: upload an explicitly selected text/Markdown source through one client, create a shared source and project note, validate exact evidence and support records, automatically accept under a test policy, retrieve the committed revision, then open its citation in a read-only browser. Exercise both pilot projects and a retry/restart failure. The test policy is not the production policy. +The disposable local vertical slice now uploads a selected source through MCP, validates and accepts an evidence-backed note under a test policy, reads the committed revision, and opens its citation in the browser. Retry, recovery and scoped-read cases have local tests. The test policy is not the production policy. Remaining work includes real pilot source and question qualification, protected owner authorization, selected native-client workflows, portability and accessibility acceptance; see the implementation plan and ledger for the next gates. Before that boundary becomes a pilot deployment, settle the open decisions listed in the architecture. Implementing a smaller slice does not reduce the PRD's full-v1 requirements for hybrid retrieval, supported source formats, four agent harnesses and portable operation. -Runtime versions will be pinned in lockfiles during scaffolding after compatibility checks. The agent pack has an instruction/version manifest, but there are no application dependency manifests, runnable service, browser implementation or deployment tests in this repository. Skill-file validation is separate from installed discovery and live workflow acceptance. +Runtime versions are locked in the Python and web workspaces. The agent pack has a contract/version manifest, local checkpointing, and an additive installer; native-client discovery and workflow acceptance remain open. The service exposes authenticated reads and contribution workflows, and the browser reads accepted knowledge with citations. Pilot corpus evaluation, protected owner authorization, cross-platform qualification, deployment, and accessibility gates remain open. Skill-file validation is separate from installed discovery and live workflow acceptance. diff --git a/docs/acceptance/2026-10-13-foundation.md b/docs/acceptance/2026-10-13-foundation.md new file mode 100644 index 0000000..79f0daa --- /dev/null +++ b/docs/acceptance/2026-10-13-foundation.md @@ -0,0 +1,238 @@ +# Foundation implementation evidence + +Date: 13 October 2026 +Scope: local development host, Python 3.14.7, Node 26.8.1, Linux. +Status: component checks PASS; disposable source-to-citation flow NOT RUN. + +## Backend + +Commands: `make check`, `make typecheck`, `./scripts/python-tool mypy src tests`, `make test`, `make schemas`, `make boundaries`, and `git diff --check`. + +Observed: Ruff passed; mypy passed for application and test modules; 33 pytest tests passed; schemas and catalog regenerated; import boundaries passed; whitespace check passed. Tests cover strict contracts, fixture catalog, exact claim and edge evidence, upload resume/hash/path constraints, text extraction maps, Git expected-head publication, acceptance receipts, idempotent retries and recovery after publication-before-receipt crashes. + +## Frontend and dependencies + +Commands: `npm run check`, `npm run build`, and `npm audit --audit-level=moderate` from `web/`. + +Observed: TypeScript, ESLint and Vitest passed (1 test); Vite production build passed; npm audit reported zero vulnerabilities. + +## Limits + +These checks run only on the stated local runtime. Python 3.12, other operating systems, native client discovery and workflows, MCP, retrieval, browser citations, real-source quality, deployment and restore remain NOT RUN. Synthetic fixtures do not satisfy the PRD's 20-source pilot evaluation. No pilot or release gate is claimed complete. + +## Authenticated canonical read continuation + +Additional commands: same backend command set plus `make check`, `make typecheck`, and `./scripts/python-tool mypy src tests`; frontend commands were rerun. + +Observed: 40 backend tests passed. Added SQLite credential hash/revocation/rotation coverage and an HTTP test that confirms missing credentials get 401, malformed IDs get 422, a cross-project read gets 403, a granted read returns the committed revision and citations, and revoked credentials get 401. Local factory and SQLite identity setup reject symlinked data paths. Frontend checks/build passed; npm audit reported zero vulnerabilities. Local `make dev` now composes the Git repository and credential store under private `GEWU_DATA_ROOT` (default `.data/gewu`); `make provision-credential ARGS="..."` provisions an operator-created credential and displays it once. After the 40-test suite, the focused API/identity group was rerun and passed (8 tests); Ruff, mypy and `git diff --check` passed again. + +This is one-node canonical reading, not search or the M3 browser flow. There is no human bootstrap/reset or browser session mechanism; an operator must control the local provisioning command and data root. Proposal submission and durable acceptance do not yet use the SQLite identity store. + +## Authenticated MCP read continuation + +The official Python MCP SDK v2.3.0 is locked as the Streamable HTTP transport. A protocol-level test connects an SDK client through the mounted MCP endpoint, lists the single `get_node` tool and reads the committed node/citations. Missing and revoked credentials return tool errors without node data. Host validation is restricted to loopback names in the local composition; public deployment host policy is not configured. The SDK's current v2 API and stdio/Streamable HTTP split are documented by the [official SDK](https://py.sdk.modelcontextprotocol.io/) and [server transport guide](https://py.sdk.modelcontextprotocol.io/run/). + +The pilot fixture now has a single unambiguous shared test policy and validates JSON duplicate-key rejection. + +## Authenticated contribution and source-to-citation continuation + +Commands: `make check`, `./scripts/python-tool mypy src tests`, `./scripts/python-tool pytest -q`, `npm --prefix web run check`, `npm --prefix web run build`, and `npm --prefix web audit --omit=dev`. + +Observed on the same local Linux/Python 3.14/Node 26 environment: 46 backend tests pass; Ruff and mypy pass; frontend TypeScript, ESLint and Vitest pass; production build succeeds; npm audit reports zero vulnerabilities. `tests/api/test_mcp_contribution.py` uses the MCP SDK client and an isolated temporary repository to upload a Markdown fixture, deny a cross-project upload, verify its hash, extract a location-mapped block, submit an attributed proposal under the test policies, confirm automatic acceptance and read the committed node with the exact citation. `tests/application/test_registry.py` seeds and parses the explicitly disposable registry. Identity revocation and publication now share the canonical-writer lock in the local composition. Catalog regenerated with all exposed contribution tool names. + +This is synthetic local evidence, not a pilot result. The artifact-store contract test now uploads identical bytes for two project grants, verifies one content-addressed artifact is readable under both grants, and verifies an ungranted project is denied. Exact retry/restart across the complete source-to-acceptance flow, native host discovery, browser citation reading, retrieval/index behavior, real policy/ownership, real-source quality, other runtimes/OSes, deployment and restore remain NOT RUN. The initial UI was health-only at this point; the authenticated reader is documented later. A later MCP contribution test verifies that a quote found at the wrong locator is rejected with `EVIDENCE_MISMATCH` before a valid proposal is auto-accepted, and that exact accepted-proposal retry returns the original receipt without another commit. The disposable local-slice gate remains open until its remaining criteria pass. + +## Explicit-file stdio connector continuation + +The `gewu-connector` entry point exposes only `list_selected_files` and `upload_selected_file` to its local MCP peer. File IDs and paths are supplied by the operator at process launch. It rejects symlinked or non-regular files, caps bytes at the service limit, requires HTTPS for non-loopback service URLs, reads the bearer token from `GEWU_TOKEN`, and returns only file metadata, content hash and extraction IDs. A private mode-0600 state file checkpoints upload IDs and finalized artifact hashes; retries query server-verified offsets and resume without resending completed chunks, including after a connector restart. + +The connector contract test simulates a restart by creating a fresh local MCP server instance against an in-process mock service. It interrupts transfer after the first chunk, interrupts again after finalization, then resumes extraction from the saved artifact. The end-to-end contribution test also invokes the connector over a real MCP Streamable HTTP session to the local GEWU ASGI service. Together they verify byte/hash equality, no source bytes or absolute paths in local tool results, rejection of an unallowlisted file ID, and rejection of a symlinked selected path. This does not prove an OS process restart/kill, public remote deployment, TLS certificate/service identity under a native host, actual client configuration, or Windows/macOS file semantics. + +Updated validation: `make check`, `make boundaries`, `./scripts/python-tool mypy src tests`, `./scripts/python-tool pytest -q`, frontend check/build, `npm audit --omit=dev`, and `git diff --check` pass; the backend suite now has 60 tests. Python dependency `httpx2` is declared directly because the connector configures bearer headers on the MCP Streamable HTTP client; its v2 transport contract is documented by the [official MCP SDK client transport guide](https://py.sdk.modelcontextprotocol.io/client/transports/) and [HTTPX2 project page](https://pypi.org/project/httpx2/). + +## Preliminary scoped lexical search + +SQLite 3.53.4 in this Python 3.14.7 Linux runtime provides FTS5. `SQLiteSearchIndex` uses a disposable FTS5 table plus a project-indexed canonical-record table, WAL journaling and `synchronous=NORMAL`; every connection closes explicitly. Query terms are quoted and bound as a parameter, project IDs use generated placeholders, and only registered/authorized project IDs reach the query. The service reads just the small project registry for scope validation. A missing index rebuilds transactionally from a pinned Git revision; a revision change triggers a rebuild. A source/commit race returns the older index revision with an `INDEX_LAG` warning. + +The tests cover HTTP/MCP search, missing/cross-project scope denial, complete citations, stale-node warnings, exact revision disclosure, budget omissions and too-small errors, FTS rebuild on a new Git revision, fresh-index reconstruction, and an injected canonical-advance/index-lag case. `EXPLAIN QUERY PLAN` on the scoped FTS query reported `SCAN node_fts VIRTUAL TABLE INDEX 0:M6`, followed by a `SEARCH indexed_nodes USING INDEX indexed_nodes_project (project_id=? AND node_id=?)`; ranking uses a temporary B-tree. This is query-shape evidence only. No 10,000-node latency or 100,000-node rebuild benchmark has run. + +The current budget unit is explicitly `unicode-codepoints-v1` over the complete canonical JSON envelope. It preserves each result with its complete citations and retains warnings while dropping lower-ranked groups. This is an interim, deterministic contract and does not represent model tokens; the tokenizer/encoding decision and offline tokenizer assets remain open. Authenticated HTTP/MCP neighbor reads return typed edges with endpoint nodes, filter node endpoints by the explicit project/shared scope, preserve edge and neighbor citations, apply freshness and index-lag warnings, and use the same complete-envelope budget. Source metadata is included only when its `ingested_for` grant intersects the authorized scope. Edges, source metadata, source-grant rows and nodes are indexed in the same transactional SQLite rebuild; schema version 4 forces existing derived indexes to rebuild. Adapter tests verify source grant filtering, while HTTP/MCP tests verify project isolation, citations and transport availability. Authenticated HTTP/MCP list_projects discovery filters registry IDs against the caller's grants. Lexical search cursors bind the query, resolved project scope, shared flag, page size and SQLite index revision; invalid cross-query cursors return 422, changed-index cursors return 409, and cursors advance only past results included under the response budget. A caller can retry a budget-truncated page with a larger budget. Neighbor pagination filters inaccessible endpoints before offset/limit, binds cursors to the same index revision, returns distinct pages through HTTP, and expires old snapshots with 409. HTTP/MCP source metadata and bounded node-history reads are now tested. Source reads return 404 when the source revision is not ingested for the requested authorized project, and history returns validated versions from the canonical Git path. Archived artifact download is now available through the authenticated project source route; browser integration for exact extraction location preview, synthesis dependency warnings, CJK/language qualification, hybrid ranking and performance gates remain NOT RUN. Search/M1.4 and the broader M1 milestone remain partial. + + +## Authenticated reader continuation + +The React reader now uses the read-only HTTP API for project discovery, scoped search, canonical note reads, source metadata and node history. It presents exact quotation text and locator details, freshness and index-lag notices, loading/empty/error states, and query/project state in the URL. The bearer credential is held in component memory, never persisted to browser storage, and the disconnect control clears the reader state. No capture, edit, approval, merge or generation action is exposed. Vite proxies `/v1` to the local API for same-origin development requests. Tests cover the connect → search → note → citation → source metadata flow and verify that localStorage remains empty; frontend type/lint/unit checks and production build pass. + +The sign-in form exchanges a human viewer credential for the planned browser session; the bearer secret is dropped after exchange. Archived source bytes are now downloadable to authorized readers; the browser does not yet provide exact location preview. The T3 collaborative preview DOM measurements at 1440×900, 1024×768 and 390×844 showed no horizontal overflow; a 320×844 resize timed out. Snapshot capture repeatedly failed. Screenshots, 320 px reflow, 200% zoom, keyboard-only end-to-end review, screen-reader review and axe results remain NOT RUN. The M3 browser and visual acceptance gates remain partial. + + +## URL-owned search facets (2026-10-14) + +Search now accepts exact tag, audience, use and domain filters through authenticated HTTP and MCP. SQLite applies each selected facet group before pagination; values within one group are ORed, while distinct groups are ANDed. Continuation cursors bind all selected facet values, so changing a filter cannot reuse an old page token. The browser initializes these filters from the URL, writes changes back to it, and clears the active cursor when a filter changes. Facet choices are informed by loaded results, while text entry permits an exact value outside those suggestions. + +Tests cover matching and non-matching HTTP filters, cursor rejection after a filter changes, MCP facet forwarding, and multiple values within a facet group. Search also accepts an empty query only with a knowledge-type filter; HTTP returns 422 for an empty untyped request. SQLite tests verify problem-only results, project isolation, and stable non-overlapping cursor pages. HTTP and MCP tests verify problem browse returns the authorized fixture. The frontend test exercises “Browse problems” and confirms that it sends an empty query with `node_types: ["problem"]` and records the type in the URL. On the local Linux/Python 3.14.7/Node 26.8.1 environment, schema/catalog/MCP-contract generation and pack-manifest verification pass; Ruff, mypy (33 source files), boundary checks, all 111 backend tests, TypeScript, ESLint, all 11 frontend tests, production build and both whitespace checks pass. At 320×800, T3 DOM measurement of the disconnected screen reports document width equal to the viewport; the navigation strip scrolls horizontally. Authenticated interaction and visual snapshots remain unverified because preview snapshots fail and the available preview is disconnected. Screenshots, 200% zoom, keyboard-only and screen-reader review, and automated accessibility results remain NOT RUN. M3.3 remains partial pending cross-project explanations and the outstanding visual/accessibility protocol. + + +## Selected-node URL restoration (2026-10-14) + +Opening a search result or related suggestion now adds `node` and `node_project` to the current scoped search URL. The node project is explicit because a visible shared record may differ from the active search project. After authentication, a direct node link performs the canonical read through the authorized project route and restores citations, history and relationships. Back navigation removes the selection and clears its private reader data; forward navigation restores it from the still-loaded result page or an authorized canonical read. Changing search or project clears the old node parameters. The route does not expose credentials or private response data. + +Frontend tests cover a direct shared-node URL after connection and back/forward movement across an opened node. T3 preview could not exercise the authenticated flow: snapshots fail and the available tab remains on the disconnected screen. The production API now accepts an optional Git commit ID for node reads, confirms it appears in that node's latest 20 reachable revisions, and returns that exact node with citations. Invalid revisions receive 422; commits absent from the node history receive 404. MCP `get_node` accepts the same optional revision. The reader makes history entries selectable and stores an explicitly selected node-file commit in `revision`; it uses the latest commit that changed the node rather than the repository HEAD, which may have advanced for unrelated records. The default node URL continues to read current accepted state. Revision-pinned views omit current-index relationships and explain why. Shared URLs and browser history restore the selected revision. API tests cover the latest node-file commit differing from repository HEAD, historical content/citations, and invalid or unknown commit IDs; UI tests cover direct historical links, selection and revision back/forward. On local Linux/Python 3.14.7/Node 26.8.1, the final checks pass: 111 backend tests, 11 frontend tests, Ruff, mypy (33 source files), import boundaries, generated schemas/catalog/MCP contract, pack manifest verification, production build and root/submodule whitespace checks. T3 authenticated behavior remains unverified. + +## Shared search URL restoration (2026-10-14) + +After browser authentication, a direct URL containing a query and exact filters now runs that scoped search and restores its results. Browser back/forward to a different query/filter route follows the same path. Query and facet edits continue updating the shareable URL without issuing requests until the user presses Search; the first draft edit creates a history entry, later edits replace it, and submit commits without a duplicate entry. Tests verify a shared query/tag/audience URL issues one scoped request, typing remains passive, and one Back/Forward step restores and reruns the corresponding prior/next search. TypeScript, ESLint and all 13 frontend tests pass; production build and the previously recorded backend/schema/pack gates pass. Authenticated T3 preview, two-minute reading task, and visual/accessibility evidence remain open. + +## Synthetic search capacity baseline (2026-10-14) + +Added `make benchmark-search` for repeatable synthetic measurements through the production SQLite search adapter. On an Intel Core i7-7700HQ laptop, Linux 7.2.5, Python 3.14.7 and SQLite 3.53.4, a 10,000-node single-project corpus produced 20 scoped results; 30 warm searches measured p50 23.8 ms and nearest-rank p95 26.7 ms. Cold index rebuild plus first search was 43.6 seconds. A 100,000-node corpus split across ten projects (10,000 each) measured 10 warm searches scoped to one project at p50 132.5 ms and p95 144.4 ms; cold rebuild plus first search was 422.3 seconds. Both observed warm adapter runs are below 300 ms, and the synthetic 100k rebuild is below ten minutes. + +These are partial R3 evidence, not a release pass: the benchmark feeds an in-memory canonical repository, excludes Git I/O and HTTP/auth overhead, repeats one valid claim shape, and does not test semantic retrieval quality or concurrency. The PRD workload, cache assumptions and target machine remain unfixed. Detailed reports: [10k project](search-benchmark-10000.json) and [100k total](search-benchmark-100000.json). `make check`, `make typecheck`, `make test` (111), `make boundaries`, frontend check (13), production build, and root/submodule whitespace checks pass with the benchmark script in the worktree. + +## OS-level publication recovery (2026-10-14) + +Added subprocess tests that call `os._exit` immediately after the canonical Git ref update and before durable receipt creation. The parent process then constructs fresh repository and acceptance-service instances, runs recovery, verifies the receipt points to the already-committed revision, checks author-scoped accepted status, and retries the proposal without advancing Git HEAD. It also starts with an empty derived index, rebuilds from that recovered canonical commit, and confirms the accepted citation is searchable. At this stage, abrupt index-rebuild termination and unrelated queued work were still open; the following evidence closes those local checks. + +## Canonical repository replacement contract (2026-10-14) + +Moved repository conflict and failure errors to the application-owned port and centralized its portable relative-path validation. The shared contract now runs against both the production Git adapter and an independent in-memory adapter: expected-head publication, immutable prior-revision reads, scoped file listings, introducing commit/history order, missing paths and rejection of stale writes and traversal paths. The existing `CanonicalNodeReader` is also exercised unchanged against the in-memory adapter and returns the same canonical node, revision and citation. Acceptance orchestration now catches only port-owned errors and has no Git adapter import. Ruff, mypy, import boundaries, and the full backend/frontend pipelines pass. The substitute proves dependency isolation and the contract cases listed here; it does not prove another backend's locking, durability, crash behavior or production equivalence. + +## Recovery gate for unrelated work (2026-10-14) + +With a proposal journal left pending after ref update, the test corrupts its expected manifest digest to force reconciliation to fail. An unrelated proposal submitted during that failure receives `RECOVERY_REQUIRED`; its proposal file is not written and canonical HEAD remains unchanged. After restoring the verified digest, explicit recovery reconstructs the first receipt, and retrying the unrelated proposal publishes successfully. This covers one unrelated queued proposal across the recovery gate; it does not claim queue-load capacity. + +The index rebuild crash test now starts from an already complete eight-node generation, publishes four additional canonical nodes, and terminates a subprocess after five FTS inserts in the new generation's transaction. SQLite retains the old eight-node rows and revision with no partial writes. A subsequent ordinary search rebuilds the current 12-node generation and returns all nodes. Together with the publication and adapter-contract cases above, this satisfies the listed local M0.6 and M0.7 acceptance checks; broader PRD corpus, platform, pilot and release gates remain separate. + + +## Human browser session continuation + +Human admin/editor/viewer credentials can exchange for an eight-hour server-side session represented by an HttpOnly, SameSite=Strict cookie. The server stores only session and CSRF digests; cookie-authenticated POST reads and sign-out require the matching CSRF header. Session creation enforces same-origin when an Origin header is present. Agent credentials are denied session creation. Browser-session principals carry a distinct auth method and cannot submit agent proposals; revoking the underlying credential makes the session unusable. The UI clears the typed bearer credential after exchange, keeps the CSRF token only in memory and signs out by revoking the server session. + +Tests cover human/agent session eligibility, cookie flags, same-origin rejection, CSRF rejection and success, sign-out, session invalidation after credential revocation, and prevention of proposal submission through a browser-admin principal. This completes only the browser-session portion of M1.1. Restricted first-admin bootstrap, owner grants and the separate digest-bound protected owner authorization/challenge flow remain open pending the owner/bootstrap decision. The visual capture issues and browser reading accessibility checks above also remain open. + + +## Proposal status continuation + +The explicit MCP catalog now includes `proposal_status`, `proposal_evaluate` and `proposal_withdraw`; the generated v1 schema includes the bounded status response record. Status returns the proposing agent's lifecycle, validation outcome/findings and durable receipt without returning the stored proposal body. Unknown proposal IDs and proposals owned by another principal both return the same unavailable result. Evaluation applies deterministic validation and authorization without writing a proposal, journal event, receipt or canonical commit. Withdrawal shares the canonical-writer lock, is idempotent, refuses accepted work, and prevents later submission of the same proposal revision. MCP tests cover valid and wrong-location evaluation, no canonical revision advance or status record before submission, author isolation, withdrawal, and denial of another agent's status/evaluation/withdrawal attempts. Recovery tests verify ref-before-receipt recovery reports accepted and prepublication recovery reports staged. + +## Local GEWU pack contract and run checkpoint + +Pack version 0.3.0 includes a `service-v1.json` snapshot generated from the actual MCP SDK tool inventory, including discovered input schemas, the operation catalog digest and generated v1 record-schema hashes. `test_agent_contract.py` creates a temporary service and compares its live tool schemas with the pinned pack snapshot. `manifest_tool.py verify` checks all module hashes, the module snapshot, nested schema hashes and capability names. `run_checkpoint.py` creates mode-0600 checkpoint files under a mode-0700 `Runs/` directory and atomically updates them; tests record scope/lens, source extraction references, explicit exclusion reasons, uncertain-operation digests, proposal revisions/receipts and output hashes. Resume rejects pack-hash drift, invalid output hashes, mismatched acceptance receipts and secret fields. Domain and MCP tests verify that node/assessment authorship cannot be attributed to a different agent. These are local synthetic checks: native host discovery/workflows, remote service identity, OS process-kill recovery, semantic quality, and the supported OS matrix remain NOT RUN. No project/lens read is exposed; the pack requires explicit user/workspace lens input and reports unavailable P1 capabilities. Backend lint, typing, all 69 tests, boundary checks, pack manifest verification and whitespace checks pass; frontend checks/build pass; npm audit reports zero vulnerabilities. + +## Agent-owned service run continuation + +The v1 MCP catalog and pack snapshot now include `run_create`, `run_checkpoint` and `run_status`. Run records are private to the authenticated agent principal, require explicit project/shared submission grants, and are stored as mode-0600 files beneath a mode-0700 run directory. Writes are atomic and serialized with identity revocation and canonical publication; creation is idempotent for the same request, while changed input under an existing run ID conflicts. Checkpoints use expected-revision compare-and-swap; identical uncertain retries return the committed revision, changed retries and stale revisions conflict, and completed/blocked runs are terminal. Tests cover create/status/checkpoint through a live MCP session, owner isolation, credential revocation, idempotent retry, changed content and stale revision rejection, and terminal state enforcement. Generated schemas/catalog and the pack contract were regenerated from the service. Local evidence only: host-side automatic run mirroring, native host resume, process-kill recovery, other operating systems and remote service identity remain NOT RUN. + + +## Project-authorized archived source download + +The HTTP reader exposes the original archived source at `/v1/projects/{project_id}/sources/{revision_id}/artifact`. The handler first resolves and validates the canonical source revision and its `ingested_for` project grant, then checks the artifact digest, stored project provenance, media type and byte length before streaming. Responses include a digest ETag, `private, no-store`, `nosniff`, and sandbox CSP headers. The authenticated-read integration test verifies byte-for-byte output, unauthenticated denial, out-of-scope/unknown revision denial, integrity metadata and revoked-credential denial. The route is read-only and does not expose storage paths. A bounded authenticated HTTP slice endpoint returns normalized and original text with the extraction offset map after confirming both the canonical source grant and project-scoped extraction. The reader exposes an exact-location action and archived-download link; its frontend test exercises both controls and renders the captured text. Browser automation snapshot failed repeatedly, so screenshots and native browser interaction are NOT RUN. At the available 391×845 mobile viewport, DOM inspection found no horizontal overflow (document width 376 px). 320 px reflow, 200% zoom, keyboard/screen-reader review, and cross-platform behavior remain NOT RUN. + + +## Authenticated service diagnostics + +The shared typed diagnostic contract is exposed by authenticated HTTP and MCP operations. It reports the live catalog SHA-256, service/API/schema versions, caller roles and readable projects, role-filtered operations for the current transport, optional explicit scope, source transfer size/chunk/media constraints, canonical/index revisions and index state. It reports `authenticated` connection status and `not_verified` workflow status separately. It contains no principal ID, credential identifier or token. HTTP and MCP integration tests verify catalog digest equality, authenticated-only access, project/shared-scope denial, read-versus-agent capability filtering, transfer limits and absent credential fields. The indexed-search integration additionally observes `not_built`, `current` and `lagging` states without diagnostics triggering a rebuild. The MCP inventory snapshot and generated schema/catalog were rebuilt from the actual server. Commands: `make schemas`, `make pack-manifest`, `python agents/gewu/scripts/manifest_tool.py verify`, `make check`, `make typecheck`, `./scripts/python-tool mypy src tests`, `make test`, `make boundaries`, and both root/submodule `git diff --check`. Observed on local Linux/Python 3.14.7: all 69 backend tests, Ruff, both mypy checks, boundaries and manifest verification pass; frontend checks/build and npm audit were rerun during the immediately preceding UI slice and passed (2 frontend tests, zero reported vulnerabilities). This remains local synthetic evidence; native connector/client workflows and installed skill-version verification remain NOT RUN. + +### Additive pack installer + +The GEWU pack was version 0.5.0 for this installer increment; the current pack is 0.6.0 and now includes a copy-based installer. In isolated temporary workspaces, six tests cover installation and removal, preservation of existing AGENTS/CLAUDE instructions and a Gemini MCP entry, preservation of unrelated skills, refusal to overwrite an occupied pack destination, rollback after a simulated copy failure, refusal to remove a modified owned routing block, and preservation of later user edits and user-added files during uninstall. The installer rejects a symlinked .agents parent and does not configure host MCP entries or store credentials. Commands: make pack-manifest, python agents/gewu/scripts/manifest_tool.py verify, make check, ./scripts/python-tool mypy src tests, and ./scripts/python-tool pytest -q tests/test_pack_installer.py; all passed on Linux/Python 3.14.7. + +This is fixture evidence for filesystem behavior only. The installer has not been run in a real project workspace; installed discovery, service connection, active-run pin behavior under a native host, host MCP setup, and workflow verification remain NOT RUN. Existing pack destinations are refused, so an update cannot silently replace a pack pinned by a run. + +### Allowlisted URL capture + +Added the authenticated MCP operation source_capture_url. The service operator enables destinations with GEWU_FETCH_ALLOWED_HOSTS, a comma-separated exact-host allowlist; empty configuration leaves all fetches blocked. The adapter accepts only HTTP(S) on standard ports, resolves and rejects every non-global address, connects to the validated address directly, rechecks each redirect, disables compressed responses, caps responses at 50 MiB and 60 seconds, and allows two concurrent captures. The MCP handler checks the authenticated agent's project submission grant before fetching, stores UTF-8 text/Markdown/HTML through the existing verified artifact store, and strips query strings/fragments from returned source URLs. Four URL-fetch tests cover allowlisted capture and pinned address use, private DNS rejection, redirect rejection and unsupported/oversize responses. The HTML parser test and authenticated MCP integration cover visible-text extraction, active-content exclusion, normalization maps, successful HTML archive/extraction and cross-project denial. Commands: make schemas, make pack-manifest, manifest verification, Ruff, mypy, focused MCP/schema/adapter tests; all passed on Linux/Python 3.14.7. + +This is synthetic fixture evidence. No public source was fetched. PDF parsing, parser process isolation, reference-only failure records, URL identity/revision updates, HTML title extraction, exact location verification against representative pages and native host workflows remain NOT RUN. HTML citation offsets point into the extracted visible text; the parser does not map them to markup byte offsets. + +### HTML extraction and final local validation + +The versioned parser now archives HTML while extracting visible text, removing script/style/template/noscript content and preserving normalization maps over the extracted text. HTML URL capture and extraction were exercised through the authenticated MCP fixture, including cross-project denial and archived original-byte verification. HTML citation offsets resolve against the stored visible-text extraction; they do not claim raw markup offsets. URL fetching now bounds DNS resolution to five seconds with at most two in-flight resolver tasks, in addition to the fetch duration, size and concurrency limits. The final local run regenerated schemas, catalog and pack manifest, verified pack integrity, passed Ruff, mypy (31 application and 60 source/test files), 85 backend tests, import boundaries, frontend TypeScript/ESLint/Vitest (2 tests), production build, npm audit (0 vulnerabilities), and both root/submodule diff checks. Runtime: Linux, Python 3.14.7, Node 26.8.1. + +No public source was fetched. PDF parsing, isolated parser processes, reference-only failure records, URL identity/revision updates, HTML title extraction, real-page exact-location verification, native host discovery/connection/workflows, accessibility/visual qualification, real pilot corpus and owner-approved policies remain open. + +## Entity consistency, search facets and reader continuation + +The domain normalizes aliases to NFC and collapsed whitespace and rejects empty, duplicate, or title-repeated aliases. Proposal validation flags exact entity title/alias collisions in the candidate's project and across other projects only when both the explicit proposal scope and caller read grants permit comparison. The MCP adapter intersects those grants and scope; the validator independently enforces the explicit scope. A match produces only a generic review finding and prevents auto-accept; it does not create an entity link or merge. Publication rechecks the actor's credential, roles and project-grant snapshot. Tests cover same-domain collision, authorized cross-project and shared matches, excluded-scope/unreadable isolation, grant snapshot comparison and normal MCP contribution flow. SQLite FTS includes aliases. Reversible merge history and protected owner authorization remain unimplemented. + +Search now accepts typed node-type filters and a stale-only filter in HTTP and MCP. SQL applies the filters before pagination; cursor fingerprints bind both filters. The reader exposes query, project, type and past-freshness filters in the URL, and separately labels accepted relationships and on-demand lexical suggestions. Canonical note detail exposes exact citations, source locations, recorded claim assessment details, relationship conditions/rationale and history. Frontend tests cover empty/error states, assessment rendering and the relationship/suggestion path. + +The source index now supports an authenticated, paginated list_sources operation over HTTP and MCP. The SQL query joins normalized source grants before pagination, so records without a grant in the explicit requested scope are not listed. The UI's Sources navigation loads that bounded catalog and offers only project-authorized archive downloads. Integration fixtures verify list ordering/pagination, denied project scope, and MCP tool behavior; the frontend test covers catalog rendering and download-link construction. + +Validation after the source-list and reader changes: schema/catalog/live MCP contract regeneration and pack manifest verification; Ruff; mypy over 33 source files; boundary checks; 102 backend tests; frontend TypeScript/ESLint/Vitest (6 tests); production web build; root and pack whitespace checks. All pass on Linux/Python 3.14.7/Node 26.8.1. T3 preview navigation works at mobile and desktop viewport settings, but snapshot automation still fails; screenshots, keyboard/screen-reader/axe review, 320 px and 200% zoom, native host workflows, real documents, measured retrieval, pilot quality, other OSes and protected owner authorization remain NOT RUN. This evidence does not close M2.4, M2.5, M3 or the release gate. + +### Problem reading and recorded facets + +Problem detail labels accepted links as approaches and related knowledge, explains when no accepted approaches are visible, distinguishes accepted links from lexical suggestions, and shows recorded tags, audience, use and domain metadata. Knowledge nodes now have explicit `assumptions` and `limitations` groups of ordinary evidence-backed claims. Validation enforces their assessor attribution, assertion/evidence digests and exact source locations; SQLite FTS searches their text; search, canonical reads and history return their citations; the UI renders their evidence type, status, method and rationale under separate headings. Domain tests cover eligible sourced claims and wrong-location rejection; SQLite tests prove retrieval and citation inclusion; the frontend test covers rendering. Multi-problem facet filtering, cross-project relationship explanations and visual/accessibility acceptance remain open. + +At a 320×800 CSS-pixel T3 preview viewport, DOM measurement found page scroll width equal to viewport width (320 px); the rail navigation now wraps and scrolls within its own row. Snapshot automation still fails, so this is a layout measurement rather than screenshot or interactive visual acceptance. + +### Evidence-backed assumptions and limitations + +Added backward-compatible `KnowledgeNode.assumptions` and `.limitations` claim groups. Each entry reuses `Claim`, so it cannot be stored without typed evidence and a support assessment bound to the exact assertion and evidence digests; claim IDs must be unique across all groups, and each evidence reference must identify its group/index path. Acceptance validates source revision, extraction integrity, quote location and assessor attribution for every group; accepted node readers, history, FTS and search citations include them. Tests prove a valid claim is eligible, a wrong-location assumption is rejected, accepted publication persists the extraction and reader returns the typed claim/citation, and SQLite search retrieves the group text with its citation; the frontend test verifies separate assumption/limitation headings and content. Schemas, operation catalog, MCP snapshot and pack manifest were regenerated and verified. Full local verification passes on Linux/Python 3.14.7/Node 26.8.1: 109 backend tests, Ruff, mypy, boundaries, six frontend tests, production build and whitespace checks. This does not complete M3.3's facet navigation, cross-project explanation or visual/accessibility gates. + +### Authorized cross-domain duplicate review + +Proposal validation now compares entity titles and aliases across projects listed in the explicit proposal scope only when the authenticated principal can read those projects. The MCP adapter computes that intersection, and the validator independently intersects its input with proposal scope. Findings disclose only that a possible duplicate exists; they do not return the matched record. Acceptance carries the actor's role/project-grant snapshot and the identity store rechecks it immediately before publication under the canonical writer lock. Regression coverage verifies authorized project/shared matches, unreadable and out-of-scope isolation, changed authority snapshots, and the normal MCP contribution path. Validation: regenerated contracts/manifests; 106 backend tests pass; Ruff, mypy, import boundaries, six frontend tests, production build and whitespace checks pass on Linux/Python 3.14.7/Node 26.8.1. Reversible merges and protected owner decisions remain unimplemented. + +### M0.7 Cross-process writer exclusion + +Two separately constructed acceptance-service instances share one repository and workflow lock. The test pauses the first publisher inside its commit section, confirms the second instance receives the stable `BUSY` error, then releases the first and verifies exactly one canonical revision was published and no receipt was fabricated for the rejected writer. The test passes as part of the 106-test backend suite. This proves the local lock boundary for simultaneous writers; it does not replace process-kill recovery, filesystem-specific lock qualification or the full shared-repository adapter contract suite. + +### M0.1 CI and automated browser continuation (2026-10-14) + +Added `.github/workflows/ci.yml` for pushes and pull requests. It installs Python 3.12 and Node 22, syncs the locked Python and npm dependencies, verifies generated schemas/catalog/MCP contract and the GEWU pack manifest, then runs backend lint/type/boundary/tests, frontend check/build, and Chromium browser tests. `make browser-test` runs the same browser suite locally. Playwright artifacts upload when a CI job fails. The hosted workflow has not run yet, so CI execution and Python 3.12/Node 22 compatibility remain unverified. + +The two Chromium tests use a routed synthetic read API and a real browser rendering the authenticated UI. One restores project, query and tag/audience facets from a shared URL, opens a result, verifies its exact citation and checks a 320 px viewport. It exposed a 40-character canonical revision expanding the page; reader metadata now wraps on narrow screens. The other confirms editing a query does not issue a search until the user submits it. Vitest discovery explicitly excludes Playwright files, keeping unit and browser runners separate. Local verification passes: 121 backend tests, 13 frontend tests, two Chromium tests, TypeScript, ESLint, Vite production build, Ruff, mypy (33 source files), boundaries, generated contracts, pack manifest and whitespace checks. The contract suite also rejects traversal-like, slash-containing and whitespace-prefixed project IDs through scope validation. These browser tests do not replace live-backend browser coverage, accessibility review, visual protocol screenshots or cross-platform acceptance. + +### M0.2 Generated read API types and allowlist continuation (2026-10-14) + +Added a deterministic OpenAPI exporter that composes the real local HTTP application under a temporary `GEWU_DATA_ROOT`, so schema generation cannot initialize or modify an operator's data. It adds canonical record schemas as components with rewritten local definitions; `openapi-typescript` generates `web/src/generated/api.ts`. HTTP route operation IDs now match the explicit transport operation catalog, and an integration test asserts exact set equality plus the exclusion of proposal and owner writes. The reader imports generated envelope, citation, source and request types and uses `satisfies` checks for its search, neighbor and source-list request bodies. Browser fixtures now include the required envelope and citation contract fields. + +`make api-types` regenerates OpenAPI and TypeScript. CI runs it with `make schemas` and fails on generated drift. The contract audit remains partial because the PRD's full contract inventory and tokenizer/profile decision are not closed. OpenAPI generation is locally exercised against the composed app; hosted CI and the chosen codepoint/tokenizer semantics remain separate evidence gaps. + +### M0.3 Cross-project policy fixture continuation (2026-10-14) + +Added a deterministic source-registration fixture ingested for both pilot projects plus shared. The same proposal publishes a Dome datapoint with exact source evidence while naming both project scopes. It is eligible only when the Dome, interactive-video, and shared policies all apply; disabling source auto-registration in the video policy yields `needs-review` and prevents auto-acceptance. Together with existing wrong-location, supported-quote/unsupported-assessment, stale-assessment, missing-policy and scope-escape tests, every deterministic M0.3 example now has an explicit expected status/finding. Ruff, boundary checks and all 123 backend tests pass. + +This fixture proves the cross-policy evaluator, not semantic assessor quality. The PRD's 20 hand-guided real sources, deep-research report and labeled expected-answer questions remain external inputs; synthetic cases do not close that pilot gate. + +### M0.4 Upload integrity and interruption continuation (2026-10-14) + +Added explicit artifact-store checks for a chunk digest that does not match its bytes, a completed upload whose whole-artifact digest differs, and an interrupted multi-chunk upload that cannot publish or expose the expected artifact. Extended the authenticated MCP source-to-citation vertical slice: premature finalize fails, `source_extract` cannot read that unfinalized artifact, a wrong chunk digest is rejected, and the same bytes subsequently upload, finalize and extract successfully. Existing coverage verifies matching chunk retry after adapter restart, text/Markdown normalization maps, immutable extraction records, shared artifact deduplication with two project grants, and explicit unavailable PDF extraction. The focused MCP flow passes. A symlink regression test confirms forged session directories and chunk symlinks are not followed; the full backend gate now has 127 passing tests. M0.4 local acceptance is complete; NotebookLM, cross-platform/parser qualification and real-source locator evaluation remain under the later pilot/input-coverage gates. + +### M0.5 Mandatory policy validation continuation (2026-10-14) + +Added a regression case proving `require_supported_assessment=false` is treated as a policy violation and cannot make an otherwise eligible proposal acceptable. Existing deterministic coverage verifies correct quote with unsupported assessment, exact-location mismatch, stale assertion assessment, evidence and author binding, scope escape, edge policy, and eligibility across every affected policy. The MCP source-to-citation integration verifies a failing proposal is retained as evaluated without a receipt, while its valid correction is eligible and accepted by policy. Ruff, import-boundary checks and all 126 backend tests pass. This satisfies M0.5's local acceptance checks; semantic assessor quality and complete merge workflow remain separately open. + +### M1.1 Role enforcement and publication revocation continuation (2026-10-14) + +The real MCP contribution test now provisions a viewer with the same project grants as an agent and confirms the viewer cannot submit proposals or begin uploads. A publication test uses the SQLite identity store and revokes the agent key between initial validation and the final publication check; submission fails with `POLICY_CHANGED`, canonical HEAD does not advance, and the proposal remains evaluated without a receipt. Ruff, mypy, import-boundary checks and all 128 backend tests pass. + +M1.1 remains partial. The protected owner CLI/session/challenge profile in the architecture is proposed, not an approved live mechanism; named project/shared owners and the first-owner bootstrap authority were not supplied. No unprotected challenge endpoint or inferred owner grant is added while those authority inputs remain unresolved. + +### M1.3 Remote connector service identity continuation (2026-10-14) + +The remote connector HTTP client now explicitly sets certificate verification before using its operator-provisioned bearer credential. A focused factory test enters the actual remote-client context and checks that the configured endpoint is HTTPS, verification is enabled, and the credential is attached as an authorization header rather than placed in the URL or tool result. The full Python quality gate passes with 129 backend tests, Ruff, mypy (33 source files) and import-boundary checks. + +This is local contract evidence only. A live remote HTTPS service, deployed certificate and native host connection are not available here, so M1.3 remains partial. + +### M3.1–M3.2 Keyboard path continuation (2026-10-14) + +The Chromium reader flow now focuses a search result and opens it with Enter, then verifies the exact cited evidence is visible. The search-submission flow presses Enter in the query field and confirms that no request is sent before submission and exactly one is sent afterward. Both browser tests pass. This adds keyboard activation evidence for the main search-to-citation path; it does not establish complete tab-order, zoom, screen-reader or accessibility acceptance. + +### M0.7 Independent Git writer race continuation (2026-10-14) + +Two separately constructed `GitCanonicalRepository` adapters start publication from the same expected head at a barrier and write distinct record paths. The compare-and-swap on the shared Git ref admits exactly one commit; both adapters observe that head, and the losing writer's path is absent from the winning tree. The test passes on the full 130-test backend suite, with Ruff, mypy and import-boundary checks. This proves the adapter-level race boundary for Git; it does not expand the shared replacement contract or filesystem/platform qualification. + +### M2.2 Active-run pack-upgrade boundary continuation (2026-10-14) + +Added a checkpoint test that creates a run under the current manifest, then supplies a simulated newer manifest with a changed module implementation and recomputed snapshot digest. With the new manifest treated as verified, resume fails with the pack-changed error and leaves the old checkpoint bytes intact. The full Python gate passes with 131 backend tests, Ruff, mypy (33 source files) and import-boundary checks. This verifies local checkpoint pinning; native clients still need installation/update and resume workflow qualification. + +### M1.4 / FR-U2 Confidence and as-of date search (2026-10-14) + +Added optional confidence values and inclusive `as_of` date bounds to the strict search request contract. SQLite applies them before pagination; the scoped cursor fingerprint includes all three values so changing a filter invalidates an existing cursor. HTTP and MCP searches exercise the filters against a fixture with distinct as-of dates, and domain validation rejects reversed ranges and repeated confidence values. The reader exposes labeled confidence checkboxes and date inputs, persists them in the URL, restores them from shared links and browser back/forward, and submits them with search. Frontend tests verify the request body and history restoration; Chromium verifies restoration from a direct URL at a narrow viewport. Generated schemas, OpenAPI, TypeScript types, service contract, and pack manifest were regenerated. Backend checks pass with 131 tests; frontend checks pass with 13 tests, build, and both Chromium tests. + +This closes the previously missing FR-U2 confidence/date filter behavior for the implemented lexical reader. Native-client and pilot usability evidence remain under M2.5/M3.4. + +The acceptance ledger now includes a separate status row for each of the 33 PRD P0 feature requirements, in addition to milestone and v1 release gates. It identifies FR-U2 as locally complete, retains partial/not-run statuses where implementation or real-host/pilot/deployment proof is missing, and keeps the PRD default-scope decision open under FR-P2. The matrix links each requirement to its current test evidence and remaining proof; it does not claim a P0 release pass. diff --git a/docs/acceptance/2026-10-14-admin-panel.md b/docs/acceptance/2026-10-14-admin-panel.md new file mode 100644 index 0000000..430d02a --- /dev/null +++ b/docs/acceptance/2026-10-14-admin-panel.md @@ -0,0 +1,33 @@ +# Minimal admin panel — 14 October 2026 + +Implemented `/admin` with human administrator sign-in, paged credential metadata, +project-scoped issuance, one-time secret display, confirmed revocation and latest +50 credential-management events. The activity log excludes runtime/agent logs. + +Baseline: application, tests, web and schema directories were already untracked; +the agent submodule and several architecture documents were already dirty. No commit +was made and unrelated changes were preserved. The shared operation catalog and +bundled agent contract/hash were refreshed for the four new HTTP operations. + +PASS: + +- `scripts/python-tool pytest -q`: 179 tests. +- `npm --prefix web run check`: TypeScript, ESLint, 17 frontend tests. +- `npm --prefix web run build`: production bundle. +- Ruff check and format check, mypy, module boundary checker. +- API tests: viewer/mixed-agent denial, no authentication, CSRF/origin, body limit, + duplicate IDs, secret-free lists/events, self-revocation rejection, durable audit, + revocation invalidates bearer authentication and existing browser sessions. +- Shared preview using disposable data: sign-in, credential/project/event reads, + credential creation (201), one-time secret displayed. No operator keys modified. +- Live web `/admin` loads at `https://msi.tail0b014a.ts.net:10000/admin`; at a requested + 390×844 viewport the preview reported 391×845 and no document horizontal overflow. +- Local API restarted through PM2; health succeeds and unauthenticated admin reads + return authentication errors. + +LIMITED: the shared preview routed successive calls between multiple clients and +repeatedly lost the signed-in state or failed snapshot calls. Full browser revocation, +keyboard walkthrough and authenticated responsive screenshot inspection remain +unverified. Component/API tests cover creation, confirmation, CSRF and revocation; +component tests cover reconnect after session expiry. No screen-reader or axe audit +was performed. The temporary fixture server was stopped after verification. diff --git a/docs/acceptance/2026-10-14-contradiction-bundle.md b/docs/acceptance/2026-10-14-contradiction-bundle.md new file mode 100644 index 0000000..3ef51ec --- /dev/null +++ b/docs/acceptance/2026-10-14-contradiction-bundle.md @@ -0,0 +1,5 @@ +# Contradiction bundle acceptance + +A synthetic proposal carries two separate factual nodes, each with its own exact citation to an archived source extraction, and a `contradicts` edge with explicit conditions and interpretation rationale. Policy explicitly permits this edge type. The domain validator accepts the complete bundle, and the acceptance integration test publishes both nodes and the edge in one canonical commit. The test reads the committed YAML back through the strict record parser and verifies both claims and the edge are preserved (`tests/domain/test_validation.py`, `tests/application/test_acceptance.py`). + +This proves deterministic validation and atomic storage of a contradiction bundle. The service does not independently determine that the facts conflict; the relationship interpretation is supplied by the proposal author and remains subject to the recorded evidence and support assessment. Real source qualification, reversible merge history, and protected owner authorization remain open. diff --git a/docs/acceptance/2026-10-14-hybrid-probe.md b/docs/acceptance/2026-10-14-hybrid-probe.md new file mode 100644 index 0000000..b370795 --- /dev/null +++ b/docs/acceptance/2026-10-14-hybrid-probe.md @@ -0,0 +1,65 @@ +# Hybrid retrieval dependency probe + +Status: implementation and compatibility evidence. This record does not meet the +PRD's labeled top-five recall target or close FR-A2. + +## Runtime + +- Linux x86_64, Python 3.14.7, SQLite 3.53.4, CPU inference. +- Disposable environment: `sentence-transformers==6.1.0`, `torch==2.14.1+cpu`, + and `sqlite-vec==0.1.9`. These packages were not installed into the project + runtime. +- `sqlite-vec` extension loaded into an in-memory SQLite database and reported + `v0.1.9`; a `vec0` table accepted normalized float32 vectors and returned the + nearest row first (distance 0.00612 versus 0.88957 for the second row). +- Model: `intfloat/multilingual-e5-small`, repository revision + `614241f622f53c4eeff9890bdc4f31cfecc418b3`; local packaged tree SHA-256 + `b96279dfc10e6c6c05782feead2f0155d7e16975d3ebc440cda0c46b9a038094`. +- Output was 384-dimensional and normalized to norm 1.0. Both query and passage + calls used the required `query: ` and `passage: ` prefixes. +- English query/passage cosine: 0.84662. Chinese query/passage cosine: 0.94244. + English query/Chinese passage cosine: 0.79640. This small hand-written probe is + not a relevance benchmark and makes no recall claim. +- End-to-end composition used the real local model with the Git, FTS, vector and + application search adapters. It returned `retrieval_mode=hybrid` with four + results. The vector generation and canonical index both reported revision + `734c31c64a22803134a76bba30fec25c8f322c8f`; model ID, artifact digest, content + profile and dimensions matched, and both indexes used the same SQLite file. + +## Code delivered + +`src/gewu/application/ports.py` defines the replaceable embedding port. +`src/gewu/adapters/embeddings.py` loads only an already-installed local model, +checks its tree digest, disables remote code and network loading, supplies E5 +prefixes, and validates row count, dimensions, finite values, and normalization. +`src/gewu/adapters/sqlite_vectors.py` stores scoped vectors in immutable +generations, records revision/model/artifact/content-profile/dimension metadata, +and atomically switches the active generation using compare-and-swap. Canonical +FTS and vectors publish in the same SQLite transaction. Prior generations remain +available for rollback, and active-generation deletion is rejected. +The `retrieval` optional dependency set is pinned and included in `uv.lock`; +`sqlite-vec` is also in the dev extra so CI exercises its native adapter. Unit +tests use an injected encoder to verify model-adapter contracts without +downloading weights. Vector-index tests cover project isolation, activation, +rollback, and invalid-vector rejection (`tests/adapters/test_sqlite_vectors.py`). +The application tests cover fusion, stable pages, cursor invalidation on model +change, generation replacement, scope/filter enforcement, labeled degradation, +and bounded one-hop graph expansion. They also verify that an injected failure +after vector activation rolls back both vector and FTS publication +(`tests/application/test_hybrid_search.py`). + +## Still required before hybrid acceptance + +- Expand citation/scope fixtures against the PRD's labeled corpus; current + tests verify graph-origin citation propagation, filter enforcement and that + cross-project neighbors do not escape scope. +- Qualify old-citation stability across real source/parser revisions and measure + cold re-embedding and generation publication against target hardware. +- Run the PRD's labeled top-five recall set and language/corpus fixtures. +- Package model assets for offline installation and probe the native vector + runtime on Windows and macOS; qualify cold rebuild time on target hardware. + +The [model card](https://huggingface.co/intfloat/multilingual-e5-small/blob/614241f622f53c4eeff9890bdc4f31cfecc418b3/README.md) +documents the candidate model. The [sqlite-vec project](https://github.com/asg017/sqlite-vec) +identifies its current pre-v1 compatibility risk; using it in the probe does not +qualify it for production storage. diff --git a/docs/acceptance/2026-10-14-owner-review.md b/docs/acceptance/2026-10-14-owner-review.md new file mode 100644 index 0000000..172c076 --- /dev/null +++ b/docs/acceptance/2026-10-14-owner-review.md @@ -0,0 +1,11 @@ +# Owner review evidence + +This evidence covers the local policy-exception flow implemented on 14 October 2026. It does not establish human/agent OS isolation, pilot ownership, deployment security or recovery backup acceptance. + +An agent proposal held solely because `AUTO_ACCEPT_UNSPECIFIED` is not published. The service creates a deterministic owner challenge only when each affected policy domain has a named project owner with a current grant. The challenge includes proposal identity and digest, affected domains, owner assignments, current policy versions, and complete before/after canonical file contents. Its canonical JSON digest is stored with the challenge. Diffs above 512 KiB fail closed. + +The owner login endpoint issues a five-minute token scoped to one challenge and service audience. The separate `/owner-mcp` server exposes only `owner_challenge_get`, `owner_diff_get` and `owner_decide`. Owner decisions require the expected proposal revision, payload digest and diff digest, a nonempty rationale, active owner account and current grant version. The store verifies the live session digest, challenge and configured audience at decision time. The decision digest and acceptance manifest bind that session ID. Identical retries from the same live session return the same result; a changed decision, session or content conflicts. + +When the same proposal is submitted again, validation runs against current policy and canonical state. Authorization is accepted only when current authorized challenge records match the proposal and complete diff and collectively cover every affected domain. The acceptance test changes the policy version after approval and confirms that the old decision cannot publish. Mandatory evidence, scope, schema and support findings cannot be waived. The acceptance manifest records each challenge, owner, owner-session ID, authorization digest, domain subset, grant version, rationale and decision time. A synthetic integration test verifies the held proposal, challenge creation, decision, resubmission and manifest record (`tests/application/test_acceptance.py::test_policy_exception_requires_exact_owner_decision_in_manifest`). The owner MCP integration test exercises login, challenge metadata, exact diff and decision (`tests/api/test_owner_session.py::test_dedicated_owner_mcp_reads_diff_and_records_decision`). Adapter tests cover binding integrity, grant checks, idempotent decision retries and changed-decision conflicts (`tests/adapters/test_sqlite_identity.py::test_owner_decision_is_exact_diff_bound_and_idempotent`). + +Remaining evidence includes first-owner bootstrap and named pilot accounts, password/TOTP reset and recovery, isolated OS identities, decision reconciliation under process crash and restore, real owner review usability, and deployment TLS/service identity qualification. The authorized challenge row remains until expiry; its exact proposal binding and committed manifest prevent reuse, but explicit `consumed` and `invalidated` lifecycle states are not implemented. diff --git a/docs/acceptance/2026-10-14-pasted-text-capture.md b/docs/acceptance/2026-10-14-pasted-text-capture.md new file mode 100644 index 0000000..2ca78ee --- /dev/null +++ b/docs/acceptance/2026-10-14-pasted-text-capture.md @@ -0,0 +1,21 @@ +# Pasted-text source capture + +## Observable behavior + +`source_capture_text` accepts an authenticated principal, an authorized project, a stable capture ID and source key, and UTF-8 plain text or Markdown up to 1 MB. It rejects empty text, NUL bytes, malformed capture IDs, oversized metadata, unauthorized projects and requests above the byte limit. An optional original URL is normalized and stripped of credentials, query and fragment data. + +The service stores the body in its private content-addressed artifact store, extracts it against the exact generated source revision, and returns a `SourceRevisionRecord` candidate marked `capture_method=pasted-text`. The operation does not register the source; the agent submits the returned candidate through the normal typed proposal workflow. + +Capture metadata is checkpointed in a private disk journal keyed by authenticated principal and capture ID. The journal binds the project and a digest of all capture inputs, stores no source body, returns the original candidate on exact retry, and rejects reuse with changed inputs or a different project. The journal retains at most 1,024 receipts. Source records also identify `url-fetch` and `local-file` capture methods; records created before this field existed load as `unspecified`. + +## Verification + +- `tests/api/test_mcp_contribution.py` exercises the live Streamable HTTP MCP path, including editor success, viewer and cross-project denial, exact retry, changed-input conflict, URL credential redaction, body digest, stored extraction and citation text, invalid metadata/control characters and the 1 MB limit. +- `tests/application/test_uploads.py` recreates the artifact-store adapter and verifies receipt recovery, principal isolation and conflict behavior across restart. +- `tests/api/test_stdio_connector.py` confirms local-file candidates identify their capture method; the URL fixture confirms `url-fetch` provenance. +- Generated JSON schemas, catalog, HTTP OpenAPI, TypeScript types and the GEWU 0.10.0 MCP contract snapshot regenerate and verify. +- Full local verification: 156 backend tests, Ruff, mypy (36 source files), import boundaries, 13 frontend tests, production build and both Chromium tests pass. + +## Remaining qualification + +This closes a synthetic local pasted-text path only. Supplied NotebookLM exports, native-host workflows, Windows/macOS parser support, representative real sources, and real-source citation accuracy remain open under M2.3 and M3.4. The receipt journal is bounded; retries after receipt eviction can create a fresh candidate. diff --git a/docs/acceptance/2026-10-14-project-creation-ui.md b/docs/acceptance/2026-10-14-project-creation-ui.md new file mode 100644 index 0000000..52c0b01 --- /dev/null +++ b/docs/acceptance/2026-10-14-project-creation-ui.md @@ -0,0 +1,26 @@ +# Project creation in administration — 14 October 2026 + +The administrator UI now includes a Projects section, existing project summaries, +and a creation form using the established `/v1/admin/projects` API. It collects a +name, stable project slug, owner ID, purpose and 1–30 research questions. Owner +identity defaults to `/v1/me`. Creation refreshes project options for account and +credential grants without reloading the page. Failures preserve the form for retry. + +The default initial policy explicitly permits source registration and supported +research record types that pass service validation. The checkbox explains this and +can require review instead. Protected intent/policy/deletion/merge changes retain +owner authorization. Cross-project includes start empty. No project is created on +the operator's behalf; the operator fills in the form and submits it. + +PASS: TypeScript, ESLint, 20 frontend tests and production build; existing project +administration API test; deployed admin page HTTP 200. The new integrated UI test +covers identity defaults, slug generation, policy and CSRF payload, server conflict +preserving inputs, successful creation, and updated account/key grant selectors. + +LIMITED: shared preview status/open calls timed out. Rendered screenshot, keyboard +and responsive browser checks could not be completed during this change. Existing +styles and semantic form controls are reused; no accessibility audit is claimed. + +The working tree already contained uncommitted application work. Changes were kept +in the admin UI, tests, CSS and setup documentation; no commit or knowledge mutation +was performed. diff --git a/docs/acceptance/2026-10-14-project-lifecycle.md b/docs/acceptance/2026-10-14-project-lifecycle.md new file mode 100644 index 0000000..f3d4d8d --- /dev/null +++ b/docs/acceptance/2026-10-14-project-lifecycle.md @@ -0,0 +1,50 @@ +# Project registry lifecycle evidence + +Status: local FR-P1 implementation acceptance. This does not establish protected +owner authorization, a named production owner, native-client behavior, or pilot +acceptance. + +## Contract + +- Project IDs are lowercase hyphen-separated slugs and are immutable. +- The canonical entry is `projects//project.md` with strict + `ProjectRecord` YAML frontmatter. Its matching initial `AutoAcceptPolicy` is + committed alongside it at `policies/.yaml`. +- Project creation requires a named owner, active status, a matching policy, + supported-assessment enforcement, and default includes that resolve to a + registered project or shared. Project and policy publish in one expected-head + commit. +- Only an authenticated non-agent administrator can access the HTTP control + routes. Browser-session writes require same-origin and CSRF checks. The streamed + create body is capped at 64 KiB. These control-plane operations do not appear in + the MCP agent catalog and do not bypass proposal validation. +- Archive is idempotent and one-way through this API. It writes a new canonical + project revision, retains prior records/citations, and makes the project + read-only. Paused and archived projects stay readable but cannot accept new + changes. Shared cannot be created or archived as a project. +- Lifecycle writes use the same authority lock as proposal publication, so an + archive cannot race an acceptance decision. + +## Evidence + +`tests/api/test_project_admin.py` verifies unauthenticated and viewer denial, +denial when an identity combines `admin` and `agent`, authorized create/list/ +archive, duplicate conflict, missing CSRF, wrong Origin, browser-session success, +and a 413 response above the body limit. `tests/application/test_registry.py` +verifies the project frontmatter and policy are read into validation context, +archive advances canonical revision and remains idempotent, the legacy JSON project +entry is promoted by canonical archive, and the shared writer lock excludes an +archive while held. `tests/domain/test_validation.py` verifies +paused and archived projects cannot auto-accept a proposal. Contract tests cover +the paused enum and slug IDs. The generated schema, OpenAPI, TypeScript contract, +agent contract snapshot and GEWU pack 0.7.0 manifest are regenerated and verified. + +Latest full local run: 153 backend tests, Ruff, mypy over 36 source files, +boundaries, 13 frontend tests, production web build, two Chromium flows, schema +and API-type generation, pack manifest verification, and both workspace +whitespace checks passed on Linux/Python 3.14.7/Node 26.8.1. + +The `owners` field is recorded as project metadata; this flow does not verify the +owner's identity or implement owner challenges/grants. First-owner bootstrap, +owner-only project intent/policy changes, Windows/macOS behavior and live remote +deployment remain open. diff --git a/docs/acceptance/2026-10-14-session-restoration.md b/docs/acceptance/2026-10-14-session-restoration.md new file mode 100644 index 0000000..f3e92d9 --- /dev/null +++ b/docs/acceptance/2026-10-14-session-restoration.md @@ -0,0 +1,30 @@ +# Restore sign-in after page reload — 14 October 2026 + +The page now restores a valid human session on mount through `GET /v1/session`, +using the existing HttpOnly cookie. It recovers an in-memory CSRF token and reloads +permissions and data. Reloading or navigating between library, administration and +agent credentials no longer requires passwords while the session remains valid. + +A stable one-way CSRF token is derived from the private session secret. An additive +`restored_csrf_digest` column preserves the original token for other tabs and already +issued sessions. Restoration neither exposes the cookie nor extends its eight-hour +expiry. Same-origin checks and a custom client header protect restoration. Expired, +revoked and signed-out sessions return 401; the UI then shows sign-in. No local or +session storage is used. A failed restored admin-page permission check preserves the +valid session rather than signing the user out of other surfaces. + +PASS: 183 backend tests; 22 frontend tests, TypeScript, ESLint and production build; +Ruff check/format, mypy, module boundaries and generated operation/agent contracts. +Regression cases include pre-upgrade migration, old-tab CSRF preservation, stable +restoration, original expiry, revocation, origin/header checks, remount restoration +in admin and library, and explicit sign-out returning to the login form. + +Live HTTPS smoke check: session restoration 200 twice, stable CSRF, unchanged +expiry, authenticated admin project list 200, sign-out with the original tab's CSRF +200, restoration after sign-out 401. The smoke check's temporary session was revoked; +no operator account password or existing user session was changed. + +LIMITED: collaborative preview navigation timed out, so a complete rendered browser +reload walkthrough was not observed. Remount behavior is covered by component tests +and the deployed HTTPS session protocol was verified directly. No knowledge records +were changed, unrelated work was preserved, and no commit was made. diff --git a/docs/acceptance/2026-10-14-transcript-locators.md b/docs/acceptance/2026-10-14-transcript-locators.md new file mode 100644 index 0000000..c745347 --- /dev/null +++ b/docs/acceptance/2026-10-14-transcript-locators.md @@ -0,0 +1,7 @@ +# Timestamped transcript locator acceptance + +The local extractor now accepts WebVTT (`.vtt`) and SubRip (`.srt`) inputs and emits timestamped cue blocks while preserving the original cue text and character-offset mapping. Transcript locators carry the cue's start and end milliseconds in addition to the quote span. Proposal validation rejects a locator when its cue timing does not exactly match the cited extraction block. The selected-file connector infers transcript provenance for these formats; an explicit provenance label is included in its retry identity. + +The evidence citation view displays transcript cue timing; a frontend reader test asserts the rendered range for a transcript citation (`web/src/shell/App.test.tsx`). Unit tests cover VTT and SRT timestamp parsing, exact offsets, malformed and reversed cues, proposal acceptance for a matching cue, rejection for a shifted cue, connector media types, and transcript provenance (`tests/adapters/test_text_extractor.py`, `tests/domain/test_validation.py`, `tests/api/test_stdio_connector.py`). Generated JSON schemas, catalog, OpenAPI and TypeScript types were refreshed. Pack contract snapshot and hashes are refreshed with GEWU 0.10.0. + +This verifies synthetic WebVTT/SubRip fixtures and the local implementation. Supplied NotebookLM export examples, real-world transcript fixtures and timestamp accuracy in the pilot source path remain open; the declared NotebookLM provenance option does not authenticate or qualify an export. Windows/macOS support and native-host workflows also remain unverified. diff --git a/docs/acceptance/2026-10-14-user-accounts.md b/docs/acceptance/2026-10-14-user-accounts.md new file mode 100644 index 0000000..bd6aaa1 --- /dev/null +++ b/docs/acceptance/2026-10-14-user-accounts.md @@ -0,0 +1,35 @@ +# Password accounts and personal agent keys — 14 October 2026 + +Implemented username/password login in the library and admin surfaces, administrator +account creation/disable, self-service password changes and `/agents` for personal +agent keys. The development proxy preserves the public Host to fix the reported +same-origin login failure. Existing bearer credentials remain usable for setup. +An initial administrator account was created at the owner's request; no password or +bearer token is recorded in this evidence document. + +PASS: + +- Full backend suite: 181 tests. +- Web check: TypeScript, ESLint and 19 component tests. +- Production web build; Ruff check/format, mypy, boundary and agent manifest checks. +- Account API scenarios: hashed passwords, generic input errors without secrets, + HTTPS/origin/CSRF/body limits, login throttling, password change invalidates all + sessions, wrong-password rejection, agent identity isolation, owner-only key + listing/revocation, grant escalation denial, read-only user limitations, account + disable cascades to agent revocation, self-disable rejection and durable accounts. +- Added targeted coverage for owned-key rotation and legacy account-key revocation + cascading to the user's agent keys. +- Live HTTPS smoke check: password login 200, Secure session cookie, authorized user + list 200, CSRF-protected sign-out 200. The test session was revoked afterward. +- Public-origin requests now pass through the development proxy; attacker-origin + requests still fail with the same-origin error. + +LIMITED: the shared preview switched between clients, stale pages and renderer +errors. The fixture page loaded, but the full visual/keyboard/password walkthrough +could not be completed reliably. The component and API tests cover the behavior; +no screen-reader or axe pass is claimed. Temporary fixture HTTPS forwarding and +server processes were removed. Native agent-host setup remains outside this slice. + +Existing dirty/untracked application state was preserved, no commit was made, and +no knowledge records were changed. Account migration adds tables and a nullable +credential owner column; no existing bearer secret is rotated during migration. diff --git a/docs/acceptance/ledger.md b/docs/acceptance/ledger.md new file mode 100644 index 0000000..4cf20b0 --- /dev/null +++ b/docs/acceptance/ledger.md @@ -0,0 +1,90 @@ +# GEWU acceptance ledger + +This ledger records the implementation-plan gates against evidence currently present in the worktree. A local fixture pass does not establish pilot, native-host, deployment, or release acceptance. “Complete” means the named gate's implementation-plan acceptance checks have recorded evidence; broader pilot and release requirements remain separate. “Partial” means only part of the named gate has qualifying evidence. “Not run” means no qualifying evidence has been recorded. Reassess affected rows whenever scope or evidence changes. + +## Evidence context + +- Source plan: [implementation plan](../IMPLEMENTATION_PLAN.md); requirements: [PRD](../PRD.md). +- Runtime for the latest full local verification: Linux, Python 3.14.7, Node 26.8.1, npm 11.19.0, Chromium (Playwright). +- Workspace base revision: `4db3f451643130fae0e003d079c05279483ce123`; implementation and evidence changes are uncommitted in the shared worktree. +- Latest commands: `make test && make check && make typecheck && make boundaries`, `npm --prefix web run check && npm --prefix web run build`, `make browser-test`, `make api-types`, `python agents/gewu/scripts/manifest_tool.py refresh && python agents/gewu/scripts/manifest_tool.py verify`, `git diff --check`, and `git -C agents/gewu diff --check`. +- Latest observed results: schemas, OpenAPI and TypeScript API types regenerate cleanly; pack 0.14.0 manifest verifies; Ruff, mypy (38 source files), boundaries, all 178 backend tests, both Chromium browser tests, including keyboard search/result activation at 320 px, TypeScript, ESLint, all 14 frontend tests, production build and whitespace checks pass. Transcript locators are asserted by a reader UI test and project-lens reads are scoped, exclude policy content and are covered over MCP. See the [transcript acceptance note](2026-10-14-transcript-locators.md). HTTP/MCP default and explicit project includes, expanded-scope authorization and scoped direct/citation/neighbor/source-list reads are covered. Canonical-registry tests confirm new parser extractions do not remove the prior content-addressed extraction used by historical citations. Authenticated pasted-text capture checks authorization, the 1 MB bound, exact retry/idempotency, receipt survival across adapter restart, artifact/extraction integrity, credential redaction and capture provenance. Search includes type, freshness, confidence and as-of-date filters; confidence/date values persist in direct URLs and filter changes expire old cursors. Scoped problem browsing and selected-node/revision restoration through browser back/forward are covered. Two independent Git repository adapters racing from one head produce exactly one published tree, with no losing-writer file. Active run checkpoints refuse resume under a changed verified pack snapshot. The connector's remote HTTP client explicitly verifies TLS certificates before sending its bearer credential. Synthetic SQLite measurements are recorded for 10k and 100k nodes. See [foundation evidence](2026-10-13-foundation.md) and the [benchmark reports](search-benchmark-10000.json), [100k scale report](search-benchmark-100000.json) for scope and limits. + +## Milestone gates + +| Gate | Status | Evidence and remaining proof | +| --- | --- | --- | +| M0.1 Foundation | COMPLETE | The plan's local foundation checks pass: recorded runtimes, 175 backend tests (including contradiction-bundle publication, sqlite-vec generation, atomic hybrid-index rollback, project-registry lifecycle tests, HTTP/MCP scope resolution, retained historical extractions across parser replacement and pasted-capture retry recovery), 14 frontend tests, lint/type checks, generated-contract and pack verification, and import-boundary checks. A GitHub Actions workflow repeats the locked Python/web checks, generated-contract and pack verification, browser installation and Chromium suite; `make browser-test` exposes the suite locally. Both Chromium tests pass, including URL-restored scoped search/citation reading at 320 px and passive query submission. The hosted workflow has not yet run; Python 3.12/Node 22 and cross-platform qualification remain open. | +| M0.2 Contracts | PARTIAL | Strict v1 models, generated record schemas/catalog, generated OpenAPI plus TypeScript read API types, and MCP snapshot checks pass. A new OpenAPI contract test asserts HTTP route operation IDs equal the explicit HTTP allowlist and exclude proposal/owner writes; browser request bodies and response records now use generated types. Unknown fields and unsafe IDs are rejected. A current PRD-to-ledger cross-check finds all 33 P0 requirement IDs tracked exactly once; the payload-level audit of each public contract against the full PRD, plus the production tokenizer/counting profile decision, remains open. | +| M0.3 Fixtures | PARTIAL | Two project/shared fixtures and wrong-location, unsupported-assessment, stale-assessment, missing-policy and scope-escape cases have explicit expected outcomes. A cross-project source bundle now proves all affected project/shared policies must permit acceptance; removing automatic registration from one project's policy changes the result to `needs-review`. Tests exercise deterministic policy handling separately from support labels; they do not score semantic assessor quality. The PRD's 20 representative real sources and labeled pilot questions are still missing. Synthetic transcript fixtures separately verify VTT/SRT cue parsing and exact cue-locator validation; see [transcript locator evidence](2026-10-14-transcript-locators.md). See `test_validation.py` and `proposal_fixtures.py`. | +| M0.4 Immutable intake | COMPLETE | Acceptance evidence covers matching retries and restart, changed chunk and incorrect digest rejection, interrupted uploads that cannot finalize or be extracted, forged session/chunk symlinks that are not followed, immutable extraction storage, text/Markdown source-location maps, byte deduplication with both project grants preserved, and explicit unavailable PDF extraction. The authenticated MCP vertical slice exercises interrupted and valid upload/extract behavior. NotebookLM exports, OS/parser qualification, and real-source locator verification remain external pilot/input-coverage work tracked under M2.3 and M3.4. | +| M0.5 Validation | COMPLETE | Exact quote-at-location, stale assessment, attribution, edge binding, project/shared policy, scope and mandatory support checks are executable. The validator rejects a policy that tries to disable required support review; invalid proposals remain staged without receipts, and routine fixture proposals are eligible and publish through policy without human approval. Evidence: `test_validation.py`, `test_mcp_contribution.py`, `test_acceptance.py`. Real assessor quality belongs to M0.3/M3.4; the broader merge/consistency workflow remains under M2.4. | +| M0.6 Durable publication | COMPLETE | Plan acceptance checks pass: exact retries return one immutable receipt/commit; changed payload conflicts; concurrent writers are excluded; only explicit records are committed; and reads work while the derived index is unbuilt. Evidence is in `test_acceptance.py`, `test_git_repository.py`, and `test_search_api.py`; crash recovery details remain tracked by M0.7. | +| M0.7 Recovery and replacement | COMPLETE | Injected failures before commit, after Git ref update, and before receipt are covered; subprocess termination at the latter ref/receipt boundaries recovers the same receipt without another commit. A killed SQLite rebuild rolls back to the last complete generation, and a normal search rebuilds from current canonical state. A bad pending-manifest digest blocks an unrelated proposal before staging; after repair, recovery writes the original receipt and the proposal publishes. Service-level concurrent writers are excluded, and two independent Git adapters racing from the same expected head prove exactly one ref update succeeds with no losing-writer file in the committed tree. The shared repository contract passes against Git and an in-memory substitute, and `CanonicalNodeReader` works unchanged with the substitute. See `test_acceptance.py`, `test_git_repository.py`, `test_sqlite_search.py`, and `test_canonical_repository_contract.py`. Broader shared replacement qualification remains open. | +| M1.1 Identity and authority | PARTIAL | Viewer credentials are denied actual MCP proposal/upload operations; browser-admin principals cannot author; rotation preserves stable agent grants; and revocation during validation is rechecked before publication. Owner provisioning/auth foundations use Argon2id, encrypted TOTP seeds, replay protection, five-failure throttling, explicit grant-versioned challenge/session bindings, and revocation. The `/auth/owner-session` endpoint requires HTTPS (loopback HTTP for development), a 4 KiB body cap and no-store responses. Policy-only holds create exact before/after diff challenges from named project owners; a separate `/owner-mcp` surface authenticates challenge-bound sessions, exposes the complete digest-checked diff, and records idempotent immutable decisions. A resubmission publishes only when current decisions cover every affected domain; the acceptance manifest records owner, challenge, decision digest, rationale, domains and grant version. Tests cover auth separation, owner-MCP operations, decision replay/change, policy-exception acceptance and manifest evidence (`test_owner_session.py`, `test_sqlite_identity.py`, `test_acceptance.py`). First-owner bootstrap, reset/recovery, actual owner OS isolation, crash-qualified decision reconciliation, named pilot owners and restore/key recovery remain open; no accounts are configured. See [owner review evidence](2026-10-14-owner-review.md). | +| M1.2 Application transports | PARTIAL | Authenticated HTTP/MCP allowlists, reads, contributions, status, diagnostics and runs have integration tests. Admin-only project registry list/create/archive control operations are separately allowlisted on HTTP and protected by bearer/session authorization, CSRF, request-size limits and canonical writer serialization. Remote TLS/deployment qualification remains. | +| M1.3 Local connector | PARTIAL | Explicit-file byte transfer, bounded resume and local MCP integration are tested. Remote endpoint URLs require HTTPS (loopback HTTP only for development), and the connector explicitly enables certificate verification before attaching the bearer credential; a factory contract test covers these settings. A live remote TLS service identity check and native host workflows remain unverified. | +| M1.4 Scoped lexical retrieval | PARTIAL | SQLite FTS, scoped search, pagination, type/facet/confidence/as-of-date filters, neighbors, history, citations, index status, and project-default resolution are tested. HTTP and MCP integration tests prove omitted includes expand a single project context to `default_includes` plus shared, explicit empty includes suppress defaults, every expanded project must be authorized (including explicitly requested neighbors), and direct node/source reads, citations, neighbors and source lists obey scope (`tests/api/test_scope_resolution.py`). Synthetic adapter runs report 10k-project warm-search p95 26.7 ms and 100k-total warm-search p95 144.4 ms; these do not include Git I/O or HTTP/auth, so end-to-end performance and target-hardware qualification remain. Hybrid retrieval and tokenizer qualification remain. | +| M1.5 Hybrid retrieval | PARTIAL | Configured local mode publishes FTS, canonical nodes and scope-partitioned vector generations in one SQLite transaction. Search fuses a stable top-100 vector candidate head with lexical ranks using RRF, then expands at most 10 seeds across at most 50 one-hop typed edges; expanded nodes retain their origin and edge in the response and citation set. All vector and graph candidates are rechecked against scope and active search filters. Cursors bind to the model/content profile, and keyword fallback is labeled without issuing a continuation cursor. Tests cover project isolation, filters, stable pages, generation replacement/retention, failed-build rollback and graph expansion. A disposable Python 3.14 CPU run of the real pinned E5 model returned `retrieval_mode=hybrid` with vector and canonical revisions equal. The candidate pair probe scored 0.84662 English and 0.94244 Chinese cosine, but this is not a relevance benchmark. Labeled top-five recall, offline redistribution, and cross-platform packaging remain open (`docs/acceptance/2026-10-14-hybrid-probe.md`). | +| M2.1 GEWU pack | PARTIAL | Pack 0.14.0 contract snapshot, generated schemas/catalog, checkpoint and manifest verification pass. MCP project-context reads return only lens fields and enforce project read grants. Real host installation and native resume are unverified. | +| M2.2 Additive installer | PARTIAL | Nine isolated installer tests pass, including fail-closed checks for traversal paths, unrecognized route names in edited state and a symlinked .agents directory. Local run checkpoints pin module hashes and reject resume when a newer verified pack snapshot changes; the existing checkpoint is preserved. Host MCP configuration, discovery, connection and active-run update behavior in native clients remain unverified. | +| M2.3 Input coverage | PARTIAL | URL, selected-file and bounded authenticated pasted-text capture paths plus Linux PDF sandbox fixtures exist. WebVTT/SubRip parsing preserves cue timestamps and quote offsets; validation requires transcript locators to match the cited cue, connector candidates are marked `transcript`, and the reader renders exact cue timestamps. Tests cover cue parsing, malformed/reversed timestamps, exact/mismatched evidence timing, media types, source provenance and rendered cue-time labels (`tests/adapters/test_text_extractor.py`, `tests/domain/test_validation.py`, `tests/api/test_stdio_connector.py`). Pasted text is UTF-8 plain text/Markdown up to 1 MB; integration coverage verifies project authorization, artifact digest, exact extracted bytes, source capture-method provenance, credential redaction and rejection of oversized or invalid input (`tests/api/test_mcp_contribution.py`). A private bounded receipt journal returns the same candidate after an exact retry, survives adapter restart, and conflicts on changed input or project scope (`tests/application/test_uploads.py`, `tests/api/test_mcp_contribution.py`). A regression test verifies that a replacement parser extraction receives a new content-addressed ID while the prior canonical extraction remains resolvable for a cited node (`tests/application/test_registry.py`). See [pasted-text capture evidence](2026-10-14-pasted-text-capture.md). Windows/macOS, supplied NotebookLM exports, representative real sources and real-source location verification remain. | +| M2.4 Linking and consistency | PARTIAL | Alias normalization, scope-authorized duplicate review, typed links and contradiction preservation are tested. A synthetic two-claim contradiction bundle passes validation and commits both separately cited nodes plus the typed relationship in one commit, with committed records read back intact (`tests/domain/test_validation.py`, `tests/application/test_acceptance.py`; [evidence](2026-10-14-contradiction-bundle.md)). Policy-only exceptions now have a protected owner workflow; reversible merge history and merge operations remain unimplemented. This is storage/contract evidence, not semantic conflict evaluation. Complete provenance qualification remains open. | +| M2.5 Native hosts and quality | NOT RUN | No qualified Codex/Claude/Antigravity/OpenCode and OS matrix; no two-agent common-document convergence or semantic sampling. | +| M3.1 Reading primitives | PARTIAL | Reader states, scope controls, keyboard activation of a search result and Enter-to-submit are exercised in Chromium; transcript citation labels have a frontend rendering assertion; a 320 px no-overflow DOM measurement exists. Feature colors now resolve through semantic surface, text, border, accent and status tokens in `web/src/styles/base.css`; the token-only stylesheet passes the UI suite, production build and both Chromium flows. Calculated foreground/background contrast for primary, secondary, muted, navigation, warning, link and error text ranges from 4.74:1 to 13.41:1. T3 preview status reports a 320 px attached page, but snapshot automation failed on two retries, so rendered screenshots, 200% zoom, complete keyboard traversal and screen-reader/axe review remain open. | +| M3.2 Search and citations | PARTIAL | Synthetic scope-to-search-to-note-to-citation/source path, direct shared query/filter URL restoration after authentication, including confidence and as-of date range, prior-search and shared selected-node/revision restoration through browser back/forward, historical citation reads and stale/error states are tested. Chromium now activates a result and submits search by keyboard; transcript citations render exact cue timestamps in the evidence location label. Query and facet typing remains passive until Search. History is limited to 20 revisions; a real two-minute task, visual/accessibility review, and stale/index-lag end-user evidence remain missing. | +| M3.3 Problem/topic reading | PARTIAL | Problem detail, accepted links versus suggestions, conditions, recorded facets, evidence-backed assumption/limitation claims and missing-approach state exist. Exact tag/audience/use/domain filters and a scoped, paginated problem browse are URL-owned and server-side; cross-project explanations and visual/accessibility acceptance remain. | +| M3.4 Pilot evaluation | NOT RUN | No real corpus, owner-approved policies, user reading task, semantic quality sample or reviewer-effort record. | + +## Full-v1 release gates + +| Gate | Status | Evidence and remaining proof | +| --- | --- | --- | +| R1 Deployment boundaries | PARTIAL | Unit/integration tests exercise sandboxed PDF extraction, URL/DNS restrictions, scope and artifact boundaries; actual deployed-service adversarial qualification remains. | +| R2 Offline and portable operation | NOT RUN | Linux development fixtures only; Windows/macOS, offline packaging and native-model workflows are unqualified. | +| R3 Product and capacity targets | PARTIAL | Synthetic adapter benchmark evidence exists at 10k nodes (30 warm queries: p50 23.8 ms, p95 26.7 ms; cold rebuild plus first search 43.6 s) and 100k nodes across ten 10k-node projects (10 warm queries scoped to one project: p50 132.5 ms, p95 144.4 ms; cold rebuild plus first search 422.3 s). Reports use this Linux i7-7700HQ/8-logical-CPU machine but an in-memory canonical repository and repeat synthetic claim shape. They omit Git disk reads, HTTP/auth, contention and real relevance; target hardware/workload decisions, queue saturation, and the 20–30-question-per-project top-five recall benchmark remain open. | +| R4 Backup, restore and migrations | NOT RUN | No independent canonical/artifact/workflow/control restore, owner-key recovery or measured RPO/RTO evidence. | +| R5 Local-to-VPS transfer | NOT RUN | No locked release image, HTTPS deployment, single-authority handover, remote native workflow or rollback evidence. | +| R6 Release coverage | NOT RUN | P0 rows remain partial or not run; release traceability, runbooks and full evidence review are unfinished. | + +## PRD P0 requirement status + +This requirement-level view complements milestone gates. `COMPLETE` means local feature acceptance has evidence; it does not imply pilot, target-client or deployment acceptance. P0 rows stay partial where a required workflow or proof is still missing. + +| Requirement | Status | Evidence and remaining proof | +| --- | --- | --- | +| FR-P1 Project registry lifecycle | COMPLETE | Authorized project listing is implemented; authenticated non-agent administrators can create projects with a named owner and matching initial policy, and archive projects. Project and policy records publish together at the PRD canonical paths in one Git commit; archives retain history and block new acceptance while preserving reads. Tests cover HTTP roles, browser CSRF/origin, body limit, duplicate IDs, create/archive idempotency, provider loading and writer-lock contention (`tests/api/test_project_admin.py`, `tests/application/test_registry.py`, `tests/domain/test_validation.py`). Protected owner grants and first-owner bootstrap remain open under FR-P8/M1.1. | +| FR-P2 Explicit scope | COMPLETE | Scope distinguishes omitted `include_projects` from an explicit list: search, neighbors and source reads resolve one project context's `default_includes` plus shared, while an explicit empty list selects only the context project. Authorization checks cover every expanded project; all-project access remains explicit. HTTP/MCP integration proves default and explicit-empty behavior for search, neighbors and source lists, rejects unauthorized default and explicit includes, and checks scoped citations plus direct node/source reads (`tests/api/test_scope_resolution.py`). Domain, service and API tests cover scoped writes, `all_projects` authorization and principal grants (`tests/application/test_search_scope.py`, `tests/domain/test_contracts.py`, `tests/api/test_search_api.py`, `tests/api/test_mcp_contribution.py`). | +| FR-P3 Shared source/entity dedupe | PARTIAL | Artifact bytes deduplicate by digest without losing project grants; entity aliases and duplicate candidates are indexed, but owner-authorized reversible entity merge is open (`tests/application/test_uploads.py`, `tests/domain/test_validation.py`). | +| FR-P5 Project lens | PARTIAL | Registry records carry north-star/questions and the pack routes ingest/curation through the selected project context; native-host execution and pilot lens usefulness are unverified. | +| FR-P8 Human and agent authority | PARTIAL | Viewer/editor/admin and agent keys, rotation, revocation, browser sessions and publication rechecks are tested. Challenge-bound owner login, isolated owner MCP decisions and manifest-recorded policy exceptions are locally tested. First-owner bootstrap, reset/recovery, actual OS isolation and named pilot owner grants remain open (`tests/adapters/test_sqlite_identity.py`, `tests/api/test_owner_session.py`, `tests/application/test_acceptance.py`). | +| FR-I1 Source intake | PARTIAL | URL, selected-file, pasted-text, timestamped transcript and Linux PDF capture paths exist. Authenticated `source_capture_text` stores bounded UTF-8 text/Markdown as a private artifact, extracts it, and returns a proposal-ready source candidate marked `pasted-text`; a stable capture ID enables exact retries from a private bounded receipt journal, while source registration still requires the normal typed proposal. Supplied NotebookLM export workflows and native-host qualification remain (`tests/adapters/test_pdf_extractor.py`, `tests/adapters/test_url_fetch.py`, `tests/api/test_mcp_contribution.py`; [pasted-text evidence](2026-10-14-pasted-text-capture.md)). | +| FR-I2 Immutable source revisions | PARTIAL | Digest-bound revisions, immutable artifacts, retry and shared byte dedupe are tested. Real URL/source identity matching and pilot source coverage remain open (`tests/application/test_uploads.py`, `tests/api/test_stdio_connector.py`). | +| FR-I3 Lens triage | PARTIAL | The agent pack instructs lens-based relevance decisions; semantic scoring/reason quality has no labeled pilot evaluation (`agents/gewu/skills/kb-ingest/SKILL.md`, M3.4). | +| FR-I4 Evidence-backed digestion | PARTIAL | Typed proposals require claim evidence and exact locators; a synthetic source-to-citation flow passes. Real-source semantic coverage remains open (`tests/domain/test_validation.py`, `tests/api/test_mcp_contribution.py`, M0.3/M3.4). | +| FR-I6 Entity linking and reversible merges | PARTIAL | NFC aliases, authorized duplicate nomination, typed links and contradiction preservation are implemented; reversible merge history and protected owner workflow remain open (`tests/domain/test_validation.py`, M2.4). | +| FR-I7 Quote and support verification | COMPLETE | Archived revision digest, extraction integrity, exact quote location, assessment attribution and required support policy are checked; invalid evidence cannot publish (`tests/domain/test_validation.py`, `tests/api/test_mcp_contribution.py`). Pilot semantic assessor quality remains M3.4. | +| FR-I8 Proposal staging and run log | COMPLETE | Mutations flow through proposal submission; durable run create/checkpoint/status and proposal receipts are covered (`tests/application/test_runs.py`, `tests/api/test_mcp_contribution.py`). Native workflow execution remains M2.5. | +| FR-I9 Ingested-for provenance | COMPLETE | Shared artifact dedupe retains both project grants and denies an ungranted project (`tests/application/test_uploads.py`, `tests/api/test_authenticated_read.py`). | +| FR-I11 Durable external runs | PARTIAL | Private bounded run state, revision-checked checkpoints, receipt identity, revocation and recovery are tested. Native-agent interruption/resume is unverified (`tests/application/test_runs.py`, `tests/api/test_mcp_contribution.py`, M2.5). | +| FR-V1 Validation | COMPLETE | Schema, scope, relationship, exact evidence and assessment checks run before publication; failed proposals remain staged (`tests/domain/test_validation.py`, `tests/application/test_acceptance.py`). Semantic reviewer quality is tracked in M3.4. | +| FR-V2 Proposal correction and exceptions | PARTIAL | Evaluate/submit/status/withdraw and policy acceptance are integrated; policy-only holds can be authorized through exact-diff owner challenges and acceptance manifests. Duplicate merge and reversible merge workflows remain incomplete (`tests/api/test_mcp_contribution.py`, M1.1/M2.4). | +| FR-V3 Policy-controlled acceptance | COMPLETE | Every affected policy must permit acceptance; mandatory schema/scope/evidence/support checks cannot be waived, and eligible fixtures publish without human review (`tests/domain/test_validation.py`, `tests/application/test_acceptance.py`). Owner-protected exceptions remain under FR-P8. | +| FR-V4 Contradiction lifecycle | PARTIAL | Contradictory claims and typed links are preserved; curator detection and end-to-end supersession/contradiction workflows need pilot qualification (M2.4/M3.4). | +| FR-V5 Durable proposal lifecycle | COMPLETE | Proposal revisions, correction invalidation, idempotent receipt, conflict, withdrawal and crash recovery are tested (`tests/application/test_acceptance.py`, `tests/application/test_runs.py`). | +| FR-A1 Typed agent/read APIs | COMPLETE | Catalogued MCP tools cover scoped reads, source intake and proposal/run operations; the HTTP API exposes reads and control diagnostics without knowledge mutation (`tests/api/test_openapi_contract.py`, `tests/api/test_agent_contract.py`). | +| FR-A2 Hybrid retrieval | PARTIAL | Configured search uses scoped vector retrieval, RRF and capped one-hop typed-edge expansion with citations; keyword mode remains the default unless a local model path and digest are configured. Unit/integration and real-model composition probes pass. The PRD's labeled top-five recall target and required packaging/language qualification remain unverified (`docs/acceptance/2026-10-14-hybrid-probe.md`). | +| FR-A3 Bounded citation responses | PARTIAL | Full serialized payload counting, citation-group truncation, omission/warning retention and too-small 413 behavior are tested. The codepoint counter is provisional, not the final tokenizer profile (`tests/adapters/test_sqlite_search.py`, `tests/api/test_search_api.py`, M1.4). | +| FR-A5 Enforced contribution boundary | PARTIAL | Service credentials and canonical data are separated from proposal APIs, and direct HTTP knowledge writes are absent. Restricted mounts and authority handover require deployment evidence (R1/R5). | +| FR-A6 Harness integration | PARTIAL | Canonical profile and reversible AGENTS/CLAUDE/GEMINI pointers install without overwriting unrelated files; host MCP configuration and OpenCode/Claude/Codex/Antigravity discovery are unqualified (`tests/test_pack_installer.py`, M2.2/M2.5). | +| FR-A9 Typed-only tools | COMPLETE | MCP exposes catalogued typed operations with no raw SQL/Cypher; proposal writes are typed and staged (`tests/api/test_agent_contract.py`, `tests/api/test_catalog.py`). | +| FR-A10 Client diagnostics | PARTIAL | Diagnostics report authenticated capability, scope, catalog, limits and index status without secrets; native-client configured/connected/workflow-verified evidence remains M2.5. | +| FR-A11 GEWU workspace pack | PARTIAL | Manifest-bound profile, protocols, skills, pipelines, service snapshot and resumable checkpoints verify locally. Native host workflows and cross-agent convergence remain M2.5. | +| FR-C3 Staleness | COMPLETE | API warnings and URL-backed stale filtering are tested; the reader presents stale state (`tests/adapters/test_sqlite_search.py`, `tests/api/test_search_api.py`, M3.2). | +| FR-U1 Project/shared navigation | PARTIAL | Authorized project selection and shared-scope controls exist; an all-project browsing view with complete cross-project markings needs qualification (M3.2/M3.3). | +| FR-U2 Search filters | COMPLETE | Type, audience, domain, project, confidence and inclusive `as_of` date-range filters execute in scoped SQLite search, bind into cursors, persist in URLs and restore after authentication. HTTP/MCP, database, reader and Chromium coverage pass (`tests/adapters/test_sqlite_search.py`, `tests/api/test_search_api.py`, `web/src/shell/App.test.tsx`, `web/e2e/reader.spec.ts`). | +| FR-U3 Node/source reading | PARTIAL | Accepted nodes, citations, archived source downloads/locations, typed neighbors and bounded history are connected. Source-to-citing-note navigation and real reading tasks remain unqualified (M3.2/M3.4). | +| FR-U6 Staleness view | PARTIAL | Stale warnings and a URL-backed stale-only shortcut exist; a fully evaluated read-only stale knowledge list remains part of UI acceptance (M3.1/M3.4). | +| FR-U9 Problem/topic reading | PARTIAL | Scoped problem browsing, accepted approaches, assumptions/limitations, source references and labeled suggestions exist. Cross-project explanations and visual/accessibility acceptance remain open (M3.3). | + +No pilot or v1 completion claim is supported by this ledger. Continue closing locally testable rows and record owner-, corpus-, host-, or deployment-dependent evidence when those inputs become available. diff --git a/docs/acceptance/search-benchmark-10000.json b/docs/acceptance/search-benchmark-10000.json new file mode 100644 index 0000000..e60b229 --- /dev/null +++ b/docs/acceptance/search-benchmark-10000.json @@ -0,0 +1,35 @@ +{ + "benchmark": "sqlite-search-adapter-synthetic-v1", + "environment": { + "logical_cpus": 8, + "platform": "Linux-7.2.5-3-omarchy-x86_64-with-glibc2.44", + "processor": "Intel(R) Core(TM) i7-7700HQ CPU @ 2.80GHz", + "python": "3.14.7", + "sqlite": "3.53.4" + }, + "evidence_scope": "synthetic adapter benchmark; excludes HTTP/auth, Git object reads, and semantic relevance", + "limitations": [ + "synthetic records reuse one valid claim/citation shape and are not a relevance corpus", + "in-memory canonical reads omit Git filesystem and HTTP/API overhead", + "the PRD hardware, cache state, concurrency, and percentile decisions are not fixed" + ], + "measurements": { + "cold_index_rebuild_and_first_search_seconds": 43.55, + "first_result_count": 20, + "synthetic_seed_seconds": 0.214, + "warm_search_ms": { + "max": 28.418, + "p50": 23.805, + "p95": 26.681 + } + }, + "workload": { + "nodes_per_project": 10000, + "page_size": 20, + "projects": 1, + "query": "interactive visitors", + "query_project": "bench-project-000", + "total_nodes": 10000, + "warm_samples": 30 + } +} diff --git a/docs/acceptance/search-benchmark-100000.json b/docs/acceptance/search-benchmark-100000.json new file mode 100644 index 0000000..2ad5a94 --- /dev/null +++ b/docs/acceptance/search-benchmark-100000.json @@ -0,0 +1,35 @@ +{ + "benchmark": "sqlite-search-adapter-synthetic-v1", + "environment": { + "logical_cpus": 8, + "platform": "Linux-7.2.5-3-omarchy-x86_64-with-glibc2.44", + "processor": "Intel(R) Core(TM) i7-7700HQ CPU @ 2.80GHz", + "python": "3.14.7", + "sqlite": "3.53.4" + }, + "evidence_scope": "synthetic adapter benchmark; excludes HTTP/auth, Git object reads, and semantic relevance", + "limitations": [ + "synthetic records reuse one valid claim/citation shape and are not a relevance corpus", + "in-memory canonical reads omit Git filesystem and HTTP/API overhead", + "the PRD hardware, cache state, concurrency, and percentile decisions are not fixed" + ], + "measurements": { + "cold_index_rebuild_and_first_search_seconds": 422.283, + "first_result_count": 20, + "synthetic_seed_seconds": 2.024, + "warm_search_ms": { + "max": 144.37, + "p50": 132.549, + "p95": 144.37 + } + }, + "workload": { + "nodes_per_project": 10000, + "page_size": 20, + "projects": 10, + "query": "interactive visitors", + "query_project": "bench-project-000", + "total_nodes": 100000, + "warm_samples": 10 + } +} diff --git a/docs/architecture/ACCEPTANCE_CONTRACT.md b/docs/architecture/ACCEPTANCE_CONTRACT.md index 4c7fd15..b70cbe8 100644 --- a/docs/architecture/ACCEPTANCE_CONTRACT.md +++ b/docs/architecture/ACCEPTANCE_CONTRACT.md @@ -1,6 +1,6 @@ # Acceptance, authority and evidence contract -Status: proposed v0.2 after [independent review](../reviews/ARCHITECTURE_SENIOR_REVIEW.md). Drafted 12 October, revised 13 October 2026. Implements the boundaries in the [architecture](ARCHITECTURE.md); schemas and executable tests are still required at M0. +Status: proposed v0.2 after [independent review](../reviews/ARCHITECTURE_SENIOR_REVIEW.md). Drafted 12 October, revised 14 October 2026. Implements the boundaries in the [architecture](ARCHITECTURE.md). Initial record/API schemas and executable tests exist; the full PRD contract audit remains open. ## Record separation @@ -39,7 +39,13 @@ Named initial operations are `search`, `get_node`, `neighbors`, `list_projects`, ## Scope and policy domains -Reads resolve requested projects plus their declared includes and shared. An `all` request is explicit. Direct IDs, redirects, source citations, graph neighbors and suggestions honor the resolved scope. When a target is outside scope, return an omission reason and a way to request wider scope. Never silently follow a link into another project. +Reads resolve a single project's `default_includes` plus shared when `include_projects` is omitted. An explicit (possibly empty) `include_projects` list replaces those defaults. Multiple explicit project contexts and an `all` request do not inherit per-project defaults; all-project access remains explicit. Every project in the expanded scope and shared library must be authorized. Direct IDs, redirects, source citations, graph neighbors and suggestions honor the resolved scope. When a target is outside scope, return an omission reason and a way to request wider scope. Never silently follow a link into another project. + +## Project registry lifecycle + +Project IDs are immutable lowercase hyphen-separated slugs. The canonical project entry is `projects//project.md` with a strict YAML frontmatter `ProjectRecord`; its initial `AutoAcceptPolicy` is published in the same Git commit under `policies/.yaml`. Project creation requires a named owner, a matching policy ID/project ID, supported-assessment enforcement, and default includes that resolve to existing projects or shared. A duplicate project or policy ID conflicts; project IDs are never reused. + +Only an authenticated non-agent administrator can list full project records, create a project, or archive one. These HTTP control-plane operations are separate from agent/MCP knowledge contribution. Browser-session writes require same-origin and CSRF checks. The project-record and policy publication uses the canonical writer lock and expected-head commit so proposal validation cannot race an archive. Archive is idempotent, writes a new canonical revision, retains all previous records and citations, and cannot be reversed through this API. `active` projects accept eligible proposals; `paused` and `archived` projects remain readable but reject new accepted changes. The reserved shared scope cannot be created or archived as a project. Admin authority does not substitute for the protected owner challenge required for owner-only intent, policy-exception, merge, delete or promotion decisions. Mutation authority follows every changed record. A project owner does not gain control of shared records or another project's records through a read include. Shared has an explicit owner grant and policy. @@ -110,19 +116,19 @@ Proposed protected-action flow: 3. The human confirms there. The owner client submits a decision through MCP; the service checks project/shared ownership and records a single-use authorization bound to proposal revision, digest, policy versions and expiry. 4. The service revalidates and accepts when every affected domain permits it. Changed content, changed authority/policy, expiration or withdrawal invalidates the decision. The agent receives status only and never receives owner credentials. -An admin bootstraps accounts, key issuance and owner grants through a separate operator control plane. This authority alone cannot bypass a protected knowledge decision. Owner decisions and their audit references survive index deletion. The account/bootstrap commands are proposed tooling, not currently runnable commands. +An admin bootstraps accounts, key issuance and owner grants through a separate operator control plane. This authority alone cannot bypass a protected knowledge decision. Owner decisions and their audit references survive index deletion. The local `gewu-owner` command provisions an owner interactively, writes only an Argon2id password hash and encrypted TOTP seed, records a security event, and prints the enrollment URI once to a TTY. It is a local operator primitive; no owner accounts or grants are provisioned in this workspace. Isolation is a deployment fact. If the agent can execute as the owner and read the owner's secrets, this mechanism cannot prove human-only authorization. Use a separate OS principal/device or a protected interactive authenticator before claiming that gate passed. Do not invent human approval from conversational text supplied by the agent. ### Proposed owner authentication profile -Use the controlled GEWU owner CLI over HTTPS to the same service's Streamable HTTP MCP endpoint. Before MCP initialization, the CLI calls a separate `POST /auth/owner-session` control endpoint with human account ID, password, TOTP and challenge ID. Password/TOTP are read directly from the human terminal without echo, never arguments/environment variables, and discarded after use. The server verifies a maintained Argon2id password hash and TOTP with a pinned library (proposed `argon2-cffi` and `pyotp`), rate limits failures and prevents reuse of an accepted TOTP timestep. Enrollment/reset requires restricted operator identity and records a security event. TOTP seeds are encrypted under a service key kept outside the database and ordinary data-backup set. Maintain that key in a separately protected, encrypted, access-controlled recovery backup. A restore verifies the key before enabling owner operations; if unavailable, owner login stays disabled until an attributable operator reset and human re-enrollment complete. Test both recovery paths before team release. +Use the controlled GEWU owner CLI over HTTPS to the same service's Streamable HTTP MCP endpoint. Before MCP initialization, the CLI calls a separate `POST /auth/owner-session` control endpoint with human account ID, password, TOTP and challenge ID. Password/TOTP are read directly from the human terminal without echo, never arguments/environment variables, and discarded after use. The server verifies an Argon2id password hash and TOTP with `argon2-cffi` and `pyotp`, limits failed attempts, prevents reuse of an accepted TOTP timestep, and binds login to an unexpired issued challenge. Enrollment records a security event; reset and recovery workflows remain unimplemented. TOTP seeds are encrypted with Fernet under `GEWU_OWNER_ENCRYPTION_KEY`, kept outside the database and ordinary data-backup set. Maintain that key in a separately protected, encrypted, access-controlled recovery backup. A restore verifies the key before enabling owner operations; if unavailable, owner login stays disabled until an attributable operator reset and human re-enrollment complete. Test both recovery paths before team release. -On success, generate a random 256-bit opaque token, store its hash in the private control database, and return it once with a five-minute expiry. Store the human principal, current grant version, service audience, allowed challenge ID and capabilities `owner:inspect`/`owner:decide`. The CLI keeps the token in memory and presents it as the MCP bearer credential; it is rejected by ordinary browser sessions and agent-key validation paths. The token cannot create arbitrary proposals or authorize another challenge. Reauthentication is required after expiry. Network operations verify TLS and the configured service identity. This custom profile is explicitly limited to GEWU's controlled clients and makes no general OAuth-interoperability claim. +On success, generate a random opaque token, store its hash in the private control database, and return it once with a five-minute expiry. Store the human principal, current grant version, service audience and allowed challenge ID. The token is rejected by ordinary browser sessions and agent-key validation paths. The dedicated `/owner-mcp` surface exposes challenge metadata, the complete digest-checked diff, and an immutable authorize/reject operation. Reauthentication is required after expiry. Network operations verify TLS and the configured service identity. This custom profile is explicitly limited to GEWU's controlled clients and makes no general OAuth-interoperability claim. -MCP operations: `owner_challenge_get(challenge_id)` returns proposal revision/digest, affected domains/policy versions, expiry and status; `owner_diff_get(challenge_id)` returns the exact bounded diff and its digest; `owner_decide(challenge_id, expected_proposal_revision, expected_payload_digest, expected_diff_digest, decision, rationale)` accepts an attributable authorize/reject decision after rechecking current grants. Oversized diffs must be fully paginated and hash-bound before confirmation; they cannot be silently truncated. +MCP operations: `owner_challenge_get(challenge_id)` returns proposal revision/digest, affected domains, expiry and status; `owner_diff_get(challenge_id)` returns the complete bounded diff and its digest; `owner_decide(challenge_id, expected_proposal_revision, expected_payload_digest, expected_diff_digest, decision, rationale)` records an attributable authorize/reject decision after rechecking current grants. The current implementation caps a challenge diff at 512 KiB and refuses to create or return a larger diff; it never silently truncates one. Pagination remains future work if real pilot diffs exceed this bound. -Challenges live in the control database with states `issued`, `authorized`, `consumed`, `expired` or `invalidated`, plus immutable transition events. Only an unexpired `issued` challenge can acquire an immutable owner decision. Exact decision retries return the same decision; altered retries fail. Multiple owners can supply separate decisions for a bundle, each bound to its domains. An authorization is consumed by one acceptance manifest containing the full non-secret decision snapshot and digest. The global writer/recovery gate ensures the manifest is checked before any decision can be reused. A crash before the SQLite consumed flag is updated is recovered from that manifest; a missing flag cannot permit another acceptance. Rejection invalidates the challenge and records its decision without publishing the proposed knowledge. +Owner challenge bindings are persisted with the proposal revision/digest, diff digest, affected domains, grant version and expiry. Challenge issue retries are idempotent and changed content conflicts; expired, out-of-grant and decided challenges cannot create new owner sessions. Policy-only proposals generate a challenge per named owner and affected domain set. Acceptance revalidation checks that authorized challenges still match the current proposal, diff, policy versions and owner grants, and that their combined domains cover the bundle. The canonical manifest records immutable decision references; an accepted proposal's manifest and receipt prevent decision replay after publication. Explicit `consumed`/`invalidated` lifecycle rows, crash-injection across control-store reconciliation, reset/recovery and OS-level owner isolation remain open. Contract tests must cover owner session used as an agent token, browser/agent token used for owner operations, expired/wrong-audience token, revoked grant, TOTP reuse, altered diff, duplicate decision and crash-before-consumed-state. The profile remains proposed until implemented and reviewed on the actual client/service deployment. diff --git a/docs/architecture/ARCHITECTURE.md b/docs/architecture/ARCHITECTURE.md index 2d1f74c..0f31ae8 100644 --- a/docs/architecture/ARCHITECTURE.md +++ b/docs/architecture/ARCHITECTURE.md @@ -114,6 +114,8 @@ Every content query carries scope. Resolve registered projects and their explici Search runs scope-filtered FTS and vector candidates, merges them with reciprocal rank fusion, and performs bounded typed-edge expansion. Start with a maximum one-hop expansion and explicit candidate/edge caps in the public contract. Return ranking explanations separately from evidence status. Related suggestions never become accepted relationships by indexing. +The local service stays in keyword mode unless `GEWU_EMBEDDING_MODEL_PATH` and `GEWU_EMBEDDING_MODEL_SHA256` are both set. The configured path must point to an already-installed model tree; inference is local-only with remote code disabled. `GEWU_EMBEDDING_MODEL_ID` defaults to the pinned multilingual E5 candidate and `GEWU_EMBEDDING_DIMENSIONS` defaults to 384. Install the `retrieval` extra to enable the optional runtime. Missing or failed vector generations produce a labeled keyword response. See the [probe and setup evidence](../acceptance/2026-10-14-hybrid-probe.md). This opt-in runtime is not a full-v1 acceptance claim. + Use normalized English token search plus language fixtures. `unicode61` alone is not a claim of Chinese word segmentation; test CJK queries and use a documented segmentation or trigram adapter when needed. Evaluate short queries separately. Search acceptance is the PRD's labeled per-project question set, not a synthetic speed result. Index updates publish all node/edge/FTS/vector changes for a chosen Git revision in one SQLite transaction. Queries use a read transaction pinned to an index revision. A model change builds a separate index generation, with model ID, artifact hash, dimensions, normalization, chunking and parser versions. Publish the new generation only after integrity and retrieval gates pass. Avoid replacing an open database file on Windows: switch the service's generation pointer and retire old connections before cleanup. The acceptance contract specifies the immutable extraction artifact and rebuild watermark procedure. @@ -166,5 +168,5 @@ Checked 12 October 2026. These sources establish available mechanisms, not GEWU - [FastAPI process workers](https://fastapi.tiangolo.com/deployment/server-workers/): multiple workers are separate processes; an in-process lock cannot serialize their writes. - [Pydantic strict validation](https://docs.pydantic.dev/latest/concepts/strict_mode/), [Vite](https://vite.dev/guide/), [TanStack Query](https://tanstack.com/query/latest/docs/framework/react/overview): typed boundaries, static assets and server-state tooling supporting the choices above. - [pypdf extraction limitations](https://pypdf.readthedocs.io/en/stable/user/extract-text.html): extraction and OCR are different capabilities; quality must be measured. -- [Sentence Transformers model loading](https://www.sbert.net/docs/package_reference/sentence_transformer/model.html) supports pinned revisions and local-only loading. The [multilingual-e5-small model card](https://huggingface.co/intfloat/multilingual-e5-small) was available in search results; direct retrieval failed, so its full configuration/license remains a mandatory probe rather than a verified selection. +- [Sentence Transformers model loading](https://www.sbert.net/docs/package_reference/sentence_transformer/model.html) supports pinned revisions and local-only loading. A disposable Linux/Python 3.14 CPU probe loaded the pinned [multilingual-e5-small model card](https://huggingface.co/intfloat/multilingual-e5-small/blob/614241f622f53c4eeff9890bdc4f31cfecc418b3/README.md) snapshot and sqlite-vec 0.1.9; see the [probe record](../acceptance/2026-10-14-hybrid-probe.md). The adapter is implemented, but vector storage/fusion, offline distribution, native cross-platform compatibility and labeled recall remain unqualified. sqlite-vec remains pre-v1 and is not yet approved as the production index backend. - [tiktoken](https://github.com/openai/tiktoken) provides named encodings for a reproducible budget convention. [Git update-ref](https://git-scm.com/docs/git-update-ref.html) supports an expected-old object check for publication. diff --git a/docs/architecture/LOCAL_AGENT.md b/docs/architecture/LOCAL_AGENT.md index 73c19ab..e9a648e 100644 --- a/docs/architecture/LOCAL_AGENT.md +++ b/docs/architecture/LOCAL_AGENT.md @@ -19,8 +19,10 @@ The intended installed layout resembles the RESEARCH workspaces: ```text / AGENTS.md # thin discovery pointer to canonical profile + CLAUDE.md # thin discovery pointer for Claude Code / Claude Desktop .agents/ AGENTS.md # GEWU role and routing + CLAUDE.md # Claude harness pointer manifest.json # pack/skill/pipeline versions and readiness protocols/ # shared evidence, scope and proposal rules skills//SKILL.md @@ -56,7 +58,7 @@ The [modularity protocol](../development/MODULARITY_PROTOCOL.md) applies to all | `kb-curate` | Scoped audit -> inspect candidates -> propose corrections/relations -> receipts | Traceable changes or a report, preserving disagreements and old revisions. | | `kb-synthesize` | Accepted inputs -> dependency inventory -> compare/compose -> evidence audit -> optional proposal | Labeled local synthesis draft, or accepted synthesis after a real receipt. | -The four skills are instruction artifacts in this change. Service schemas, executable tools and client acceptance remain unimplemented. The manifest declares no verified service compatibility yet. The pack can organize explicitly requested local drafts; it must report the missing service instead of fabricating tool calls, source revisions or accepted knowledge. +The four skills are instruction artifacts, and the service has authenticated Streamable HTTP reads and contribution tools. The `gewu-connector` stdio program bridges explicitly selected local files to that service without exposing file bytes or credentials in model-visible tool responses. It is launched with one or more stable file IDs and paths, plus a service URL; the operator supplies `GEWU_TOKEN` through the host's protected environment rather than a command-line argument. The connector requires HTTPS for non-loopback services and stores resumable upload checkpoints in a private state file. The pack manifest is bound to generated wire schemas and now has a reversible workspace installer, but host MCP configuration, native client workflows and remote deployment remain unqualified. The authenticated `source_capture_url` MCP operation is enabled only when the operator configures the exact public-host allowlist in `GEWU_FETCH_ALLOWED_HOSTS`; URL and selected-file intake support UTF-8 text, Markdown, HTML visible-text extraction and PDF. Linux PDF extraction runs inside bubblewrap; scanned PDFs return an explicit no-text result, and Windows/macOS parsing remain unqualified. ## Run and resume contract diff --git a/docs/decisions/ADR-001-admin-credential-panel.md b/docs/decisions/ADR-001-admin-credential-panel.md new file mode 100644 index 0000000..5f0a41b --- /dev/null +++ b/docs/decisions/ADR-001-admin-credential-panel.md @@ -0,0 +1,34 @@ +# ADR-001: Human administrator credential panel + +Status: implemented, 14 October 2026. + +The service now exposes a separate `/admin` web page for human administrators to list, +create and revoke bearer credentials and read recent credential-management activity. +The research library's knowledge remains read-only in the browser. + +The panel reuses the existing human browser session and admin role. Agent identities, +including identities holding both agent and admin roles, cannot administer credentials. +Browser writes require the session CSRF token and same-origin requests. Credential +responses use `Cache-Control: private, no-store`; only creation returns the new secret. +The panel holds secrets in memory, never browser storage. Revoke confirmation names the +key, and the service rejects revocation of the key backing the caller's current session. + +The strongest alternative was retaining CLI-only provisioning. That avoids a network +credential-management surface but does not provide the requested operator workflow. +The first administrator still requires local operator provisioning; the panel cannot +bootstrap anonymous access. Owner review authentication remains separate. + +An additive `admin_events` SQLite table records time, actor, action and credential ID. +The event and its successful credential mutation commit in one transaction. No raw +secrets, digests, source bodies or arbitrary application log messages enter this table. +The initial log covers panel/API creation and revocation only; CLI actions and historical +credentials are not backfilled. Runtime logs and agent run history remain outside this +first slice. Credential listing is paged; activity is limited to the latest 50 events. + +Existing credentials and sessions require no migration. Reverting the UI and routes +leaves the additive table harmlessly in place. Credentials issued through the panel +remain usable by the existing identity store and can be revoked locally. + +Validation: API authorization, CSRF, origin, request bounds, conflicts, secret omission, +durable audit, bearer/session revocation, frontend credential creation and confirmation +flows. Rendered checks use a disposable data root, separate from operator state. diff --git a/docs/decisions/ADR-002-password-accounts-and-personal-agent-keys.md b/docs/decisions/ADR-002-password-accounts-and-personal-agent-keys.md new file mode 100644 index 0000000..8f94337 --- /dev/null +++ b/docs/decisions/ADR-002-password-accounts-and-personal-agent-keys.md @@ -0,0 +1,59 @@ +# ADR-002: Password accounts and personal agent keys + +Status: implemented, 14 October 2026. + +Human users sign in with a username/password through `/v1/users/session`. Agent hosts +continue to use independent, high-entropy bearer keys. This replaces bearer-key-only +human login as the default UI while retaining the existing operator key for initial +account setup and recovery. Protected owner review remains a separate password/TOTP, +challenge-bound workflow. + +Administrators create and disable accounts with viewer, editor or admin roles and +project grants. Editors and administrators issue keys for their own agents at +`/v1/me/agent-credentials`; viewers retain read-only access. The server derives the +owner from the authenticated account and checks requested grants against current +account grants in the same database transaction as issuance. An agent has its own +principal identity for proposals/runs. Users can only list and revoke their own keys. +Administrators can still inspect and revoke all keys. + +Passwords are hashed with the existing Argon2id implementation. Login uses an +HttpOnly, SameSite=Strict session cookie, with Secure on HTTPS. Public password login +requires HTTPS; localhost is allowed for development. Browser writes require CSRF +and the existing same-origin validation. The development proxy preserves the public +Host, allowing TLS-terminating deployments to validate the browser's actual Origin. +Forwarded scheme handling remains Uvicorn's trusted-proxy responsibility; application +code does not trust arbitrary forwarding headers. + +Five failed logins for a username within 15 minutes block further attempts until +that window expires. A shared 100-failure window bounds random-username attempts. +Unknown/disabled usernames use a dummy Argon2 verification and generic failure text. +This initial limiter favors a small private service; shared lockouts can be abused +for denial of service. Larger deployments should add an edge rate limiter. + +Page loads restore existing human browser sessions through `GET /v1/session` with +an explicit same-origin client header. The endpoint returns expiry, a CSRF token and +account kind; it never returns the session cookie or bearer key. A stable one-way +CSRF token is derived from the private session secret and its digest is saved beside +the original CSRF digest. This additive migration preserves pre-upgrade sessions and +old tabs, while preventing reloads or simultaneous tabs from invalidating each other. +Restoration does not extend the original session lifetime. No browser storage is used. + +Changing a password requires the current password, invalidates every browser session +for that account, and preserves independent agent keys. Disabling an account revokes +its login identity and its agent keys atomically under the canonical authority lock. +Activity events include account creation, disable and password changes, without +passwords or bearer secrets. Sensitive validation errors omit submitted inputs. + +The strongest alternative is external OIDC/SSO. It avoids local password management +but adds an external identity dependency beyond the requested minimal private-service +workflow. There is no anonymous registration or email recovery in this slice. + +Migration is additive: `user_accounts`, login failure tracking, and a nullable owner +column on credentials. Existing bearer keys and browser sessions continue to work. +Account login identities have an unrecoverable random bearer digest; only password +sign-in creates their sessions. Rolling back the routes/UI preserves existing keys; +new human accounts require the updated identity implementation. + +Validation covers login/CSRF/TLS/body limits, password hashing and changes, session +invalidation, user ownership, grant escalation, agent/human separation, disabled users, +login throttling, preserved existing credentials, UI setup and personal key issuance. diff --git a/docs/development/DEVELOPMENT_PROTOCOL.md b/docs/development/DEVELOPMENT_PROTOCOL.md index 924cfe2..3aa4299 100644 --- a/docs/development/DEVELOPMENT_PROTOCOL.md +++ b/docs/development/DEVELOPMENT_PROTOCOL.md @@ -34,7 +34,13 @@ Keep scope, search query, filters and selected node/revision in the URL. Browser Cancel obsolete requests or ignore their results, including obsolete loading and error states. Query polling exists only for a visible need and cannot overlap indefinitely. Avoid a global real-time event system before the product requires it. -Fetch through the typed API layer. Components receive enough data to render complete states: loading, empty, unavailable, forbidden, stale, index-lagged and successful. A valid empty search is different from a failed request. Profile before adding broad memoization or custom comparison functions. Sanitize Markdown rendering and scope reading typography so source headings cannot restyle navigation. +Fetch through the typed API layer. Components receive enough data to render complete states: loading, empty, unavailable, forbidden, stale, index-lagged and successful. A valid empty search is different from a failed request. Apply the shared component and rendering guidance below. Sanitize Markdown rendering and scope reading typography so source headings cannot restyle navigation. + +### Shared UI modules and render performance + +Build repeated administration and reading patterns from application-owned components with small contracts: labels, data props, actions, loading/empty/error states and focus behavior. Keep feature-specific data fetching and mutations in the owning feature module; shared primitives own presentation and interaction mechanics. Use semantic lists for repeated records and a shared dialog primitive for add/edit flows so focus trapping, Escape handling and focus restoration stay consistent. Reuse token-based styles rather than cloning one-off control styles. + +Keep state in the smallest feature that needs it so typing or selection does not rebuild the whole application shell. Give mapped records stable keys. Keep callbacks and derived props stable where a memoized feature boundary depends on them. Use `React.memo` for measured or structurally expensive subtrees with stable props, and `useMemo`/`useCallback` for costly derivation or identity-sensitive boundaries; avoid blanket memoization and custom equality code without a profile. Preserve mounted DOM only when it protects useful form or navigation state, and avoid rendering large hidden collections unnecessarily. Use virtualization only after measurement shows a list exceeds practical DOM cost. Verify actual rerender reduction with React profiling when optimizing a hot path. ## Verification proportional to the change diff --git a/docs/development/MODULARITY_PROTOCOL.md b/docs/development/MODULARITY_PROTOCOL.md index a2ed84f..edc6d7a 100644 --- a/docs/development/MODULARITY_PROTOCOL.md +++ b/docs/development/MODULARITY_PROTOCOL.md @@ -63,4 +63,4 @@ At scaffolding, add import/dependency checks and reusable contract test suites f Early acceptance examples: swap an embedding provider and rebuild without changing search consumers; substitute an extraction-stage implementation while preserving old citations; replace a frontend evidence panel without changing the read API; resume an agent run with its original pinned skill version after a newer version is installed. These are required design tests to implement, not existing passes. -Primary documentation above was checked on 13 October 2026. No module implementation or replacement test exists yet. +Primary documentation above was checked on 13 October 2026. The canonical repository port now owns its stable errors, path rules, revision-history semantics and a shared behavioral contract run against both the Git adapter and an independent in-memory implementation. `CanonicalNodeReader` also returns the same accepted node and citations against the substitute without consumer changes. This demonstrates dependency isolation and the checked contract subset; it does not establish durability, locking, crash recovery or production equivalence for another backend. Broader fault, concurrency and filesystem-specific qualification remains required. diff --git a/ecosystem.config.cjs b/ecosystem.config.cjs new file mode 100644 index 0000000..aa5c7b5 --- /dev/null +++ b/ecosystem.config.cjs @@ -0,0 +1,42 @@ +'use strict'; + +const fs = require('node:fs'); +const path = require('node:path'); + +const root = __dirname; +const nodePath = (process.env.PATH || '').split(path.delimiter); +const npmBin = nodePath + .map((directory) => path.join(directory, 'npm')) + .find((candidate) => fs.existsSync(candidate)); + +if (!npmBin) { + throw new Error('npm must be available on PATH when starting the GEWU PM2 ecosystem'); +} + +module.exports = { + apps: [ + { + name: 'gewu-api', + cwd: root, + script: path.join(root, 'scripts/python-tool'), + interpreter: '/usr/bin/bash', + args: 'uvicorn gewu.bootstrap.app:create_local_app --factory --host 127.0.0.1 --port 8000', + env: { + GEWU_DATA_ROOT: path.join(root, '.data/gewu'), + GEWU_MCP_ALLOWED_HOSTS: 'msi.tail0b014a.ts.net:8443,100.67.41.125:8443', + }, + autorestart: true, + time: true, + }, + { + name: 'gewu-web', + cwd: root, + script: npmBin, + interpreter: 'none', + args: '--prefix web run dev -- --host 127.0.0.1', + env: {}, + autorestart: true, + time: true, + }, + ], +}; diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..82190e3 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,75 @@ +[project] +name = "gewu" +version = "0.1.0" +description = "Evidence-backed multi-project knowledge service" +requires-python = ">=3.12,<3.15" +dependencies = [ + "fastapi>=0.115,<1", + "httpx2>=2.13,<3", + "mcp>=2,<3", + "pydantic>=2.10,<3", + "portalocker>=3.2,<4", + "PyYAML>=6,<7", + "regex>=2024.11,<2027", + "uvicorn[standard]>=0.34,<1", + "argon2-cffi>=25.1,<26", + "pyotp>=2.9,<3", + "cryptography>=48,<51", +] + +[project.scripts] +gewu-connector = "gewu.transports.stdio_connector:main" +gewu-owner = "gewu.bootstrap.owner_cli:main" + +[dependency-groups] +dev = [ + "httpx>=0.28,<1", + "mypy>=1.14,<2", + "pytest>=8.3,<10", + "ruff>=0.9,<1", + "sqlite-vec==0.1.9", + "types-pyyaml>=6,<7", + "types-regex>=2024.11,<2027", +] + +[project.optional-dependencies] +retrieval = [ + "sentence-transformers==6.1.0", + "sqlite-vec==0.1.9", +] +dev = [ + "httpx>=0.28,<1", + "mypy>=1.14,<2", + "pytest>=8.3,<10", + "regex>=2024.11,<2027", + "ruff>=0.9,<1", + "sqlite-vec==0.1.9", + "types-pyyaml>=6,<7", + "types-regex>=2024.11,<2027", +] + +[build-system] +requires = ["hatchling>=1.27,<2"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["src/gewu"] + +[tool.pytest.ini_options] +pythonpath = ["src"] +testpaths = ["tests"] + +[tool.ruff] +line-length = 100 +target-version = "py312" +src = ["src", "tests"] + +[tool.ruff.lint] +select = ["E", "F", "I", "UP", "B"] + +[tool.mypy] +python_version = "3.12" +mypy_path = "src" +strict = true +plugins = ["pydantic.mypy"] + diff --git a/schema/fixtures/pilot-projects.json b/schema/fixtures/pilot-projects.json new file mode 100644 index 0000000..647f305 --- /dev/null +++ b/schema/fixtures/pilot-projects.json @@ -0,0 +1,54 @@ +{ + "schema_version": 1, + "projects": [ + { + "project_id": "dome-performance", + "title": "Dome interactive content", + "status": "active", + "description": "Research for interactive content designed for dome environments.", + "owners": ["pilot-owner"], + "north_star": "Create compelling interactive experiences for dome audiences.", + "key_questions": ["How do spatial constraints affect interaction design?"], + "watch_list": ["attendance", "screen visibility"], + "default_includes": ["shared"], + "autonomy_policy_id": "dome-pilot-policy" + }, + { + "project_id": "interactive-video", + "title": "Interactive video platform and service", + "status": "active", + "description": "Research for interactive video products and services.", + "owners": ["pilot-owner"], + "north_star": "Understand useful and sustainable interactive video experiences.", + "key_questions": ["How do teams control branch growth?"], + "watch_list": ["authoring cost", "state management"], + "default_includes": ["shared"], + "autonomy_policy_id": "video-pilot-policy" + } + ], + "test_policies": [ + { + "policy_id": "dome-pilot-policy", + "policy_version": 1, + "project_id": "dome-performance", + "automatic_node_types": ["problem", "technique", "case", "datapoint"], + "automatic_source_registration": true, + "require_supported_assessment": true + }, + { + "policy_id": "video-pilot-policy", + "policy_version": 1, + "project_id": "interactive-video", + "automatic_node_types": ["problem", "technique", "case", "datapoint"], + "automatic_source_registration": true, + "require_supported_assessment": true + }, + { + "policy_id": "shared", + "policy_version": 1, + "project_id": "shared", + "automatic_source_registration": true, + "require_supported_assessment": true + } + ] +} diff --git a/schema/v1/catalog.json b/schema/v1/catalog.json new file mode 100644 index 0000000..c23515a --- /dev/null +++ b/schema/v1/catalog.json @@ -0,0 +1,454 @@ +{ + "api_version": "v1", + "operations": [ + { + "capability": "health:read", + "mutates_knowledge": false, + "name": "health", + "transports": [ + "http" + ] + }, + { + "capability": "service:diagnostics", + "mutates_knowledge": false, + "name": "diagnostics", + "transports": [ + "http", + "mcp" + ] + }, + { + "capability": "identity:session", + "mutates_knowledge": false, + "name": "browser_session_create", + "transports": [ + "http" + ] + }, + { + "capability": "identity:session", + "mutates_knowledge": false, + "name": "browser_session_restore", + "transports": [ + "http" + ] + }, + { + "capability": "identity:session", + "mutates_knowledge": false, + "name": "browser_session_revoke", + "transports": [ + "http" + ] + }, + { + "capability": "identity:session", + "mutates_knowledge": false, + "name": "account_login", + "transports": [ + "http" + ] + }, + { + "capability": "identity:session", + "mutates_knowledge": false, + "name": "account_current", + "transports": [ + "http" + ] + }, + { + "capability": "identity:session", + "mutates_knowledge": false, + "name": "account_change_password", + "transports": [ + "http" + ] + }, + { + "capability": "identity:session", + "mutates_knowledge": false, + "name": "account_list_agent_credentials", + "transports": [ + "http" + ] + }, + { + "capability": "identity:session", + "mutates_knowledge": false, + "name": "account_create_agent_credential", + "transports": [ + "http" + ] + }, + { + "capability": "identity:session", + "mutates_knowledge": false, + "name": "account_revoke_agent_credential", + "transports": [ + "http" + ] + }, + { + "capability": "credential:admin", + "mutates_knowledge": false, + "name": "admin_list_accounts", + "transports": [ + "http" + ] + }, + { + "capability": "credential:admin", + "mutates_knowledge": false, + "name": "admin_create_account", + "transports": [ + "http" + ] + }, + { + "capability": "credential:admin", + "mutates_knowledge": false, + "name": "admin_update_account", + "transports": [ + "http" + ] + }, + { + "capability": "credential:admin", + "mutates_knowledge": false, + "name": "admin_disable_account", + "transports": [ + "http" + ] + }, + { + "capability": "owner:authenticate", + "mutates_knowledge": false, + "name": "owner_session_create", + "transports": [ + "http" + ] + }, + { + "capability": "credential:admin", + "mutates_knowledge": false, + "name": "admin_list_credentials", + "transports": [ + "http" + ] + }, + { + "capability": "credential:admin", + "mutates_knowledge": false, + "name": "admin_create_credential", + "transports": [ + "http" + ] + }, + { + "capability": "credential:admin", + "mutates_knowledge": false, + "name": "admin_update_credential", + "transports": [ + "http" + ] + }, + { + "capability": "credential:admin", + "mutates_knowledge": false, + "name": "admin_revoke_credential", + "transports": [ + "http" + ] + }, + { + "capability": "credential:admin", + "mutates_knowledge": false, + "name": "admin_list_events", + "transports": [ + "http" + ] + }, + { + "capability": "project:admin", + "mutates_knowledge": false, + "name": "admin_list_projects", + "transports": [ + "http" + ] + }, + { + "capability": "project:admin", + "mutates_knowledge": false, + "name": "project_create", + "transports": [ + "http" + ] + }, + { + "capability": "project:admin", + "mutates_knowledge": false, + "name": "project_update_content", + "transports": [ + "http" + ] + }, + { + "capability": "project:admin", + "mutates_knowledge": false, + "name": "project_archive", + "transports": [ + "http" + ] + }, + { + "capability": "knowledge:read", + "mutates_knowledge": false, + "name": "list_projects", + "transports": [ + "http", + "mcp" + ] + }, + { + "capability": "knowledge:read", + "mutates_knowledge": false, + "name": "get_project_context", + "transports": [ + "mcp" + ] + }, + { + "capability": "knowledge:read", + "mutates_knowledge": false, + "name": "search", + "transports": [ + "http", + "mcp" + ] + }, + { + "capability": "knowledge:read", + "mutates_knowledge": false, + "name": "list_sources", + "transports": [ + "http", + "mcp" + ] + }, + { + "capability": "knowledge:read", + "mutates_knowledge": false, + "name": "get_node", + "transports": [ + "http", + "mcp" + ] + }, + { + "capability": "knowledge:read", + "mutates_knowledge": false, + "name": "node_history", + "transports": [ + "http", + "mcp" + ] + }, + { + "capability": "knowledge:read", + "mutates_knowledge": false, + "name": "get_source", + "transports": [ + "http", + "mcp" + ] + }, + { + "capability": "knowledge:read", + "mutates_knowledge": false, + "name": "get_source_artifact", + "transports": [ + "http" + ] + }, + { + "capability": "knowledge:read", + "mutates_knowledge": false, + "name": "get_source_location", + "transports": [ + "http" + ] + }, + { + "capability": "knowledge:read", + "mutates_knowledge": false, + "name": "neighbors", + "transports": [ + "http", + "mcp" + ] + }, + { + "capability": "proposal:submit", + "mutates_knowledge": false, + "name": "source_capture_url", + "transports": [ + "mcp" + ] + }, + { + "capability": "proposal:submit", + "mutates_knowledge": false, + "name": "source_capture_text", + "transports": [ + "mcp" + ] + }, + { + "capability": "proposal:submit", + "mutates_knowledge": false, + "name": "source_upload_begin", + "transports": [ + "mcp" + ] + }, + { + "capability": "proposal:submit", + "mutates_knowledge": false, + "name": "source_upload_status", + "transports": [ + "mcp" + ] + }, + { + "capability": "proposal:submit", + "mutates_knowledge": false, + "name": "source_upload_chunk", + "transports": [ + "mcp" + ] + }, + { + "capability": "proposal:submit", + "mutates_knowledge": false, + "name": "source_upload_finalize", + "transports": [ + "mcp" + ] + }, + { + "capability": "proposal:submit", + "mutates_knowledge": false, + "name": "source_extract", + "transports": [ + "mcp" + ] + }, + { + "capability": "proposal:submit", + "mutates_knowledge": false, + "name": "source_extraction_block", + "transports": [ + "mcp" + ] + }, + { + "capability": "proposal:submit", + "mutates_knowledge": true, + "name": "proposal_submit", + "transports": [ + "mcp" + ] + }, + { + "capability": "proposal:evaluate", + "mutates_knowledge": false, + "name": "proposal_evaluate", + "transports": [ + "mcp" + ] + }, + { + "capability": "proposal:read", + "mutates_knowledge": false, + "name": "proposal_status", + "transports": [ + "mcp" + ] + }, + { + "capability": "proposal:withdraw", + "mutates_knowledge": false, + "name": "proposal_withdraw", + "transports": [ + "mcp" + ] + }, + { + "capability": "run:write", + "mutates_knowledge": false, + "name": "run_create", + "transports": [ + "mcp" + ] + }, + { + "capability": "run:write", + "mutates_knowledge": false, + "name": "run_checkpoint", + "transports": [ + "mcp" + ] + }, + { + "capability": "run:read", + "mutates_knowledge": false, + "name": "run_status", + "transports": [ + "mcp" + ] + }, + { + "capability": "owner:review", + "mutates_knowledge": false, + "name": "owner_challenge_get", + "transports": [ + "owner_mcp" + ] + }, + { + "capability": "owner:review", + "mutates_knowledge": false, + "name": "owner_diff_get", + "transports": [ + "owner_mcp" + ] + }, + { + "capability": "owner:decide", + "mutates_knowledge": false, + "name": "owner_decide", + "transports": [ + "owner_mcp" + ] + }, + { + "capability": "local:file:read", + "mutates_knowledge": false, + "name": "list_selected_files", + "transports": [ + "stdio" + ] + }, + { + "capability": "proposal:submit", + "mutates_knowledge": false, + "name": "upload_selected_file", + "transports": [ + "stdio" + ] + } + ], + "schema_version": 1 +} diff --git a/schema/v1/openapi.json b/schema/v1/openapi.json new file mode 100644 index 0000000..f221c90 --- /dev/null +++ b/schema/v1/openapi.json @@ -0,0 +1,9952 @@ +{ + "components": { + "schemas": { + "AcceptanceManifest": { + "additionalProperties": false, + "properties": { + "accepted_node_ids": { + "default": [], + "items": { + "maxLength": 128, + "minLength": 1, + "pattern": "^[a-zA-Z0-9][a-zA-Z0-9._:-]*$", + "type": "string" + }, + "title": "Accepted Node Ids", + "type": "array" + }, + "actor_id": { + "maxLength": 128, + "minLength": 1, + "pattern": "^[a-zA-Z0-9][a-zA-Z0-9._:-]*$", + "title": "Actor Id", + "type": "string" + }, + "before_revision": { + "pattern": "^(?:[a-f0-9]{40}|[a-f0-9]{64})$", + "title": "Before Revision", + "type": "string" + }, + "changed_files": { + "items": { + "$ref": "#/components/schemas/AcceptanceManifest__ChangedFile" + }, + "maxItems": 200, + "minItems": 1, + "title": "Changed Files", + "type": "array" + }, + "credential_id": { + "maxLength": 128, + "minLength": 1, + "pattern": "^[a-zA-Z0-9][a-zA-Z0-9._:-]*$", + "title": "Credential Id", + "type": "string" + }, + "decided_at": { + "format": "date-time", + "title": "Decided At", + "type": "string" + }, + "owner_decisions": { + "default": [], + "items": { + "$ref": "#/components/schemas/AcceptanceManifest__OwnerDecisionReference" + }, + "maxItems": 100, + "title": "Owner Decisions", + "type": "array" + }, + "payload_digest": { + "pattern": "^[a-f0-9]{64}$", + "title": "Payload Digest", + "type": "string" + }, + "policy_bindings": { + "items": { + "$ref": "#/components/schemas/AcceptanceManifest__PolicyBinding" + }, + "title": "Policy Bindings", + "type": "array" + }, + "proposal_id": { + "maxLength": 128, + "minLength": 1, + "pattern": "^[a-zA-Z0-9][a-zA-Z0-9._:-]*$", + "title": "Proposal Id", + "type": "string" + }, + "proposal_revision": { + "minimum": 1, + "title": "Proposal Revision", + "type": "integer" + }, + "schema_version": { + "const": 1, + "default": 1, + "title": "Schema Version", + "type": "integer" + } + }, + "required": [ + "proposal_id", + "proposal_revision", + "payload_digest", + "before_revision", + "changed_files", + "actor_id", + "credential_id", + "policy_bindings", + "decided_at" + ], + "title": "AcceptanceManifest", + "type": "object" + }, + "AcceptanceManifest__ChangedFile": { + "additionalProperties": false, + "properties": { + "content_sha256": { + "pattern": "^[a-f0-9]{64}$", + "title": "Content Sha256", + "type": "string" + }, + "path": { + "maxLength": 500, + "minLength": 1, + "title": "Path", + "type": "string" + }, + "schema_version": { + "const": 1, + "default": 1, + "title": "Schema Version", + "type": "integer" + } + }, + "required": [ + "path", + "content_sha256" + ], + "title": "ChangedFile", + "type": "object" + }, + "AcceptanceManifest__OwnerDecisionReference": { + "additionalProperties": false, + "properties": { + "challenge_id": { + "maxLength": 128, + "minLength": 1, + "pattern": "^[a-zA-Z0-9][a-zA-Z0-9._:-]*$", + "title": "Challenge Id", + "type": "string" + }, + "decided_at": { + "format": "date-time", + "title": "Decided At", + "type": "string" + }, + "decision": { + "const": "authorize", + "default": "authorize", + "title": "Decision", + "type": "string" + }, + "decision_digest": { + "pattern": "^[a-f0-9]{64}$", + "title": "Decision Digest", + "type": "string" + }, + "domains": { + "items": { + "maxLength": 128, + "minLength": 1, + "pattern": "^[a-zA-Z0-9][a-zA-Z0-9._:-]*$", + "type": "string" + }, + "maxItems": 100, + "minItems": 1, + "title": "Domains", + "type": "array" + }, + "grant_version": { + "minimum": 1, + "title": "Grant Version", + "type": "integer" + }, + "owner_principal_id": { + "maxLength": 128, + "minLength": 1, + "pattern": "^[a-zA-Z0-9][a-zA-Z0-9._:-]*$", + "title": "Owner Principal Id", + "type": "string" + }, + "owner_session_id": { + "pattern": "^[a-f0-9]{64}$", + "title": "Owner Session Id", + "type": "string" + }, + "rationale": { + "maxLength": 2000, + "minLength": 1, + "title": "Rationale", + "type": "string" + }, + "schema_version": { + "const": 1, + "default": 1, + "title": "Schema Version", + "type": "integer" + } + }, + "required": [ + "challenge_id", + "owner_principal_id", + "owner_session_id", + "decision_digest", + "domains", + "grant_version", + "rationale", + "decided_at" + ], + "title": "OwnerDecisionReference", + "type": "object" + }, + "AcceptanceManifest__PolicyBinding": { + "additionalProperties": false, + "properties": { + "policy_id": { + "maxLength": 128, + "minLength": 1, + "pattern": "^[a-zA-Z0-9][a-zA-Z0-9._:-]*$", + "title": "Policy Id", + "type": "string" + }, + "policy_version": { + "minimum": 1, + "title": "Policy Version", + "type": "integer" + }, + "project_id": { + "maxLength": 128, + "minLength": 1, + "pattern": "^[a-zA-Z0-9][a-zA-Z0-9._:-]*$", + "title": "Project Id", + "type": "string" + }, + "schema_version": { + "const": 1, + "default": 1, + "title": "Schema Version", + "type": "integer" + } + }, + "required": [ + "project_id", + "policy_id", + "policy_version" + ], + "title": "PolicyBinding", + "type": "object" + }, + "AccountCreateBody": { + "additionalProperties": false, + "properties": { + "password": { + "maxLength": 1024, + "minLength": 12, + "title": "Password", + "type": "string" + }, + "project_ids": { + "items": { + "maxLength": 128, + "minLength": 1, + "pattern": "^[a-zA-Z0-9][a-zA-Z0-9._:-]*$", + "type": "string" + }, + "maxItems": 50, + "title": "Project Ids", + "type": "array" + }, + "role": { + "default": "editor", + "enum": [ + "viewer", + "editor", + "admin" + ], + "title": "Role", + "type": "string" + }, + "username": { + "pattern": "^[a-z0-9][a-z0-9._-]{0,63}$", + "title": "Username", + "type": "string" + } + }, + "required": [ + "username", + "password" + ], + "title": "AccountCreateBody", + "type": "object" + }, + "AccountSummary": { + "properties": { + "active": { + "title": "Active", + "type": "boolean" + }, + "principal_id": { + "title": "Principal Id", + "type": "string" + }, + "project_ids": { + "items": { + "type": "string" + }, + "title": "Project Ids", + "type": "array" + }, + "role": { + "title": "Role", + "type": "string" + }, + "username": { + "title": "Username", + "type": "string" + } + }, + "required": [ + "username", + "principal_id", + "role", + "project_ids", + "active" + ], + "title": "AccountSummary", + "type": "object" + }, + "AccountUpdateBody": { + "additionalProperties": false, + "properties": { + "project_ids": { + "items": { + "maxLength": 128, + "minLength": 1, + "pattern": "^[a-zA-Z0-9][a-zA-Z0-9._:-]*$", + "type": "string" + }, + "maxItems": 50, + "title": "Project Ids", + "type": "array" + }, + "role": { + "enum": [ + "viewer", + "editor", + "admin" + ], + "title": "Role", + "type": "string" + } + }, + "required": [ + "role" + ], + "title": "AccountUpdateBody", + "type": "object" + }, + "AdminEvent": { + "properties": { + "action": { + "title": "Action", + "type": "string" + }, + "actor": { + "title": "Actor", + "type": "string" + }, + "credential_id": { + "title": "Credential Id", + "type": "string" + }, + "event_id": { + "title": "Event Id", + "type": "integer" + }, + "occurred_at": { + "title": "Occurred At", + "type": "string" + } + }, + "required": [ + "event_id", + "occurred_at", + "actor", + "action", + "credential_id" + ], + "title": "AdminEvent", + "type": "object" + }, + "AgentCreateBody": { + "additionalProperties": false, + "properties": { + "name": { + "pattern": "^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$", + "title": "Name", + "type": "string" + }, + "project_ids": { + "items": { + "maxLength": 128, + "minLength": 1, + "pattern": "^[a-zA-Z0-9][a-zA-Z0-9._:-]*$", + "type": "string" + }, + "maxItems": 50, + "minItems": 1, + "title": "Project Ids", + "type": "array" + } + }, + "required": [ + "name", + "project_ids" + ], + "title": "AgentCreateBody", + "type": "object" + }, + "AutoAcceptPolicy": { + "additionalProperties": false, + "properties": { + "automatic_edge_types": { + "default": [], + "items": { + "enum": [ + "solves", + "addresses", + "causes", + "supports", + "contradicts", + "instance_of", + "part_of", + "precedes", + "supersedes", + "compared_with", + "derived_from", + "applies_to" + ], + "type": "string" + }, + "title": "Automatic Edge Types", + "type": "array" + }, + "automatic_node_types": { + "default": [], + "items": { + "enum": [ + "finding", + "problem", + "technique", + "case", + "entity", + "event", + "concept", + "datapoint", + "synthesis" + ], + "type": "string" + }, + "title": "Automatic Node Types", + "type": "array" + }, + "automatic_source_registration": { + "default": false, + "title": "Automatic Source Registration", + "type": "boolean" + }, + "policy_id": { + "maxLength": 128, + "minLength": 1, + "pattern": "^[a-zA-Z0-9][a-zA-Z0-9._:-]*$", + "title": "Policy Id", + "type": "string" + }, + "policy_version": { + "minimum": 1.0, + "title": "Policy Version", + "type": "integer" + }, + "project_id": { + "maxLength": 128, + "minLength": 1, + "pattern": "^[a-zA-Z0-9][a-zA-Z0-9._:-]*$", + "title": "Project Id", + "type": "string" + }, + "require_owner_decision_for": { + "default": [ + "delete", + "merge", + "promote", + "intent", + "policy" + ], + "items": { + "enum": [ + "delete", + "merge", + "promote", + "intent", + "policy" + ], + "type": "string" + }, + "title": "Require Owner Decision For", + "type": "array" + }, + "require_supported_assessment": { + "default": true, + "title": "Require Supported Assessment", + "type": "boolean" + }, + "schema_version": { + "const": 1, + "default": 1, + "title": "Schema Version", + "type": "integer" + } + }, + "required": [ + "policy_id", + "policy_version", + "project_id" + ], + "title": "AutoAcceptPolicy", + "type": "object" + }, + "BudgetProfile": { + "additionalProperties": false, + "properties": { + "encoding": { + "maxLength": 100, + "minLength": 1, + "title": "Encoding", + "type": "string" + }, + "schema_version": { + "const": 1, + "default": 1, + "title": "Schema Version", + "type": "integer" + }, + "tokenizer": { + "maxLength": 100, + "minLength": 1, + "title": "Tokenizer", + "type": "string" + } + }, + "required": [ + "tokenizer", + "encoding" + ], + "title": "BudgetProfile", + "type": "object" + }, + "Confidence": { + "enum": [ + "low", + "medium", + "high" + ], + "title": "Confidence", + "type": "string" + }, + "CredentialCreateBody": { + "additionalProperties": false, + "properties": { + "credential_id": { + "maxLength": 128, + "minLength": 1, + "pattern": "^[a-zA-Z0-9][a-zA-Z0-9._:-]*$", + "title": "Credential Id", + "type": "string" + }, + "principal_id": { + "maxLength": 128, + "minLength": 1, + "pattern": "^[a-zA-Z0-9][a-zA-Z0-9._:-]*$", + "title": "Principal Id", + "type": "string" + }, + "project_ids": { + "items": { + "maxLength": 128, + "minLength": 1, + "pattern": "^[a-zA-Z0-9][a-zA-Z0-9._:-]*$", + "type": "string" + }, + "maxItems": 50, + "title": "Project Ids", + "type": "array" + }, + "role": { + "enum": [ + "viewer", + "editor", + "agent", + "admin" + ], + "title": "Role", + "type": "string" + } + }, + "required": [ + "credential_id", + "principal_id", + "role" + ], + "title": "CredentialCreateBody", + "type": "object" + }, + "CredentialSummary": { + "properties": { + "credential_id": { + "title": "Credential Id", + "type": "string" + }, + "owner_principal_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Owner Principal Id" + }, + "principal_id": { + "title": "Principal Id", + "type": "string" + }, + "project_ids": { + "items": { + "type": "string" + }, + "title": "Project Ids", + "type": "array" + }, + "revoked": { + "title": "Revoked", + "type": "boolean" + }, + "roles": { + "items": { + "type": "string" + }, + "title": "Roles", + "type": "array" + } + }, + "required": [ + "credential_id", + "principal_id", + "roles", + "project_ids", + "revoked" + ], + "title": "CredentialSummary", + "type": "object" + }, + "CredentialUpdateBody": { + "additionalProperties": false, + "properties": { + "project_ids": { + "items": { + "maxLength": 128, + "minLength": 1, + "pattern": "^[a-zA-Z0-9][a-zA-Z0-9._:-]*$", + "type": "string" + }, + "maxItems": 50, + "title": "Project Ids", + "type": "array" + }, + "role": { + "enum": [ + "viewer", + "editor", + "agent", + "admin" + ], + "title": "Role", + "type": "string" + } + }, + "required": [ + "role" + ], + "title": "CredentialUpdateBody", + "type": "object" + }, + "CurrentUser": { + "properties": { + "password_account": { + "title": "Password Account", + "type": "boolean" + }, + "principal_id": { + "title": "Principal Id", + "type": "string" + }, + "project_ids": { + "items": { + "type": "string" + }, + "title": "Project Ids", + "type": "array" + }, + "roles": { + "items": { + "type": "string" + }, + "title": "Roles", + "type": "array" + } + }, + "required": [ + "principal_id", + "roles", + "project_ids", + "password_account" + ], + "title": "CurrentUser", + "type": "object" + }, + "DomainError": { + "additionalProperties": false, + "properties": { + "code": { + "$ref": "#/components/schemas/DomainError__ErrorCode" + }, + "findings": { + "default": [], + "items": { + "$ref": "#/components/schemas/DomainError__Finding" + }, + "title": "Findings", + "type": "array" + }, + "message": { + "maxLength": 1000, + "minLength": 1, + "title": "Message", + "type": "string" + }, + "request_id": { + "maxLength": 128, + "minLength": 1, + "pattern": "^[a-zA-Z0-9][a-zA-Z0-9._:-]*$", + "title": "Request Id", + "type": "string" + }, + "retryable": { + "title": "Retryable", + "type": "boolean" + }, + "schema_version": { + "const": 1, + "default": 1, + "title": "Schema Version", + "type": "integer" + } + }, + "required": [ + "code", + "message", + "retryable", + "request_id" + ], + "title": "DomainError", + "type": "object" + }, + "DomainError__ErrorCode": { + "enum": [ + "SCOPE_REQUIRED", + "OUT_OF_SCOPE", + "UNAUTHORIZED", + "OWNER_DECISION_REQUIRED", + "POLICY_MISSING", + "EVIDENCE_MISMATCH", + "SUPPORT_UNAVAILABLE", + "BASE_CONFLICT", + "IDEMPOTENCY_CONFLICT", + "BUDGET_TOO_SMALL", + "UPLOAD_EXPIRED", + "BUSY", + "RECOVERY_REQUIRED" + ], + "title": "ErrorCode", + "type": "string" + }, + "DomainError__Finding": { + "additionalProperties": false, + "properties": { + "code": { + "maxLength": 128, + "minLength": 1, + "pattern": "^[a-zA-Z0-9][a-zA-Z0-9._:-]*$", + "title": "Code", + "type": "string" + }, + "message": { + "maxLength": 1000, + "minLength": 1, + "title": "Message", + "type": "string" + }, + "path": { + "anyOf": [ + { + "maxLength": 256, + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Path" + }, + "schema_version": { + "const": 1, + "default": 1, + "title": "Schema Version", + "type": "integer" + } + }, + "required": [ + "code", + "message" + ], + "title": "Finding", + "type": "object" + }, + "EvidenceReference": { + "additionalProperties": false, + "properties": { + "assertion_digest": { + "pattern": "^[a-f0-9]{64}$", + "title": "Assertion Digest", + "type": "string" + }, + "assertion_path": { + "maxLength": 256, + "minLength": 1, + "title": "Assertion Path", + "type": "string" + }, + "extraction_id": { + "pattern": "^[a-f0-9]{64}$", + "title": "Extraction Id", + "type": "string" + }, + "locator": { + "$ref": "#/components/schemas/Locator" + }, + "normalization_profile": { + "const": "unicode-nfc-lf-v1", + "title": "Normalization Profile", + "type": "string" + }, + "quote": { + "maxLength": 8000, + "minLength": 1, + "title": "Quote", + "type": "string" + }, + "schema_version": { + "const": 1, + "default": 1, + "title": "Schema Version", + "type": "integer" + }, + "source_revision_id": { + "maxLength": 128, + "minLength": 1, + "pattern": "^[a-zA-Z0-9][a-zA-Z0-9._:-]*$", + "title": "Source Revision Id", + "type": "string" + } + }, + "required": [ + "source_revision_id", + "extraction_id", + "assertion_path", + "assertion_digest", + "quote", + "locator", + "normalization_profile" + ], + "title": "EvidenceReference", + "type": "object" + }, + "ExtractionRecord": { + "additionalProperties": false, + "properties": { + "blocks": { + "items": { + "$ref": "#/components/schemas/ExtractionRecord__ExtractionBlock" + }, + "maxItems": 100000, + "title": "Blocks", + "type": "array" + }, + "extraction_id": { + "pattern": "^[a-f0-9]{64}$", + "title": "Extraction Id", + "type": "string" + }, + "normalization_profile": { + "const": "unicode-nfc-lf-v1", + "title": "Normalization Profile", + "type": "string" + }, + "parser_config_sha256": { + "pattern": "^[a-f0-9]{64}$", + "title": "Parser Config Sha256", + "type": "string" + }, + "parser_name": { + "maxLength": 100, + "minLength": 1, + "title": "Parser Name", + "type": "string" + }, + "parser_version": { + "maxLength": 100, + "minLength": 1, + "title": "Parser Version", + "type": "string" + }, + "schema_version": { + "const": 1, + "default": 1, + "title": "Schema Version", + "type": "integer" + }, + "source_revision_id": { + "maxLength": 128, + "minLength": 1, + "pattern": "^[a-zA-Z0-9][a-zA-Z0-9._:-]*$", + "title": "Source Revision Id", + "type": "string" + }, + "status": { + "enum": [ + "complete", + "partial", + "unavailable" + ], + "title": "Status", + "type": "string" + }, + "warnings": { + "default": [], + "items": { + "type": "string" + }, + "title": "Warnings", + "type": "array" + } + }, + "required": [ + "extraction_id", + "source_revision_id", + "parser_name", + "parser_version", + "parser_config_sha256", + "normalization_profile", + "status", + "blocks" + ], + "title": "ExtractionRecord", + "type": "object" + }, + "ExtractionRecord__ExtractionBlock": { + "additionalProperties": false, + "properties": { + "block_id": { + "maxLength": 128, + "minLength": 1, + "pattern": "^[a-zA-Z0-9][a-zA-Z0-9._:-]*$", + "title": "Block Id", + "type": "string" + }, + "normalization_map": { + "items": { + "maxItems": 2, + "minItems": 2, + "prefixItems": [ + { + "type": "integer" + }, + { + "type": "integer" + } + ], + "type": "array" + }, + "maxItems": 100000, + "title": "Normalization Map", + "type": "array" + }, + "normalized_text": { + "maxLength": 100000, + "title": "Normalized Text", + "type": "string" + }, + "ordinal": { + "minimum": 0, + "title": "Ordinal", + "type": "integer" + }, + "original_text": { + "maxLength": 100000, + "title": "Original Text", + "type": "string" + }, + "page": { + "anyOf": [ + { + "minimum": 1, + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Page" + }, + "schema_version": { + "const": 1, + "default": 1, + "title": "Schema Version", + "type": "integer" + }, + "time_end_ms": { + "anyOf": [ + { + "minimum": 0, + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Time End Ms" + }, + "time_start_ms": { + "anyOf": [ + { + "minimum": 0, + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Time Start Ms" + } + }, + "required": [ + "block_id", + "ordinal", + "original_text", + "normalized_text", + "normalization_map" + ], + "title": "ExtractionBlock", + "type": "object" + }, + "Finding": { + "additionalProperties": false, + "properties": { + "code": { + "maxLength": 128, + "minLength": 1, + "pattern": "^[a-zA-Z0-9][a-zA-Z0-9._:-]*$", + "title": "Code", + "type": "string" + }, + "message": { + "maxLength": 1000, + "minLength": 1, + "title": "Message", + "type": "string" + }, + "path": { + "anyOf": [ + { + "maxLength": 256, + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Path" + }, + "schema_version": { + "const": 1, + "default": 1, + "title": "Schema Version", + "type": "integer" + } + }, + "required": [ + "code", + "message" + ], + "title": "Finding", + "type": "object" + }, + "HTTPValidationError": { + "properties": { + "detail": { + "items": { + "$ref": "#/components/schemas/ValidationError" + }, + "title": "Detail", + "type": "array" + } + }, + "title": "HTTPValidationError", + "type": "object" + }, + "IssuedCredential": { + "properties": { + "credential_id": { + "title": "Credential Id", + "type": "string" + }, + "token": { + "title": "Token", + "type": "string" + } + }, + "required": [ + "credential_id", + "token" + ], + "title": "IssuedCredential", + "type": "object" + }, + "KnowledgeNode": { + "additionalProperties": false, + "properties": { + "agent_id": { + "maxLength": 128, + "minLength": 1, + "pattern": "^[a-zA-Z0-9][a-zA-Z0-9._:-]*$", + "title": "Agent Id", + "type": "string" + }, + "aliases": { + "default": [], + "items": { + "type": "string" + }, + "maxItems": 100, + "title": "Aliases", + "type": "array" + }, + "as_of": { + "anyOf": [ + { + "format": "date-time", + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "As Of" + }, + "assumptions": { + "default": [], + "items": { + "$ref": "#/components/schemas/KnowledgeNode__Claim" + }, + "maxItems": 100, + "title": "Assumptions", + "type": "array" + }, + "audience": { + "default": [], + "items": { + "type": "string" + }, + "maxItems": 20, + "title": "Audience", + "type": "array" + }, + "claim_support": { + "enum": [ + "pending", + "supported", + "unsupported" + ], + "title": "Claim Support", + "type": "string" + }, + "claims": { + "items": { + "$ref": "#/components/schemas/KnowledgeNode__Claim" + }, + "maxItems": 100, + "minItems": 1, + "title": "Claims", + "type": "array" + }, + "confidence": { + "$ref": "#/components/schemas/KnowledgeNode__Confidence" + }, + "created": { + "format": "date-time", + "title": "Created", + "type": "string" + }, + "demographic": { + "anyOf": [ + { + "maxLength": 1000, + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Demographic" + }, + "domain": { + "default": [], + "items": { + "type": "string" + }, + "maxItems": 20, + "title": "Domain", + "type": "array" + }, + "lens_fit": { + "maxProperties": 50, + "patternProperties": { + "^[a-zA-Z0-9][a-zA-Z0-9._:-]*$": { + "$ref": "#/components/schemas/KnowledgeNode__LensFit" + } + }, + "propertyNames": { + "maxLength": 128, + "minLength": 1 + }, + "title": "Lens Fit", + "type": "object" + }, + "lifecycle": { + "$ref": "#/components/schemas/KnowledgeNode__Lifecycle" + }, + "limitations": { + "default": [], + "items": { + "$ref": "#/components/schemas/KnowledgeNode__Claim" + }, + "maxItems": 100, + "title": "Limitations", + "type": "array" + }, + "merged_from": { + "default": [], + "items": { + "maxLength": 128, + "minLength": 1, + "pattern": "^[a-zA-Z0-9][a-zA-Z0-9._:-]*$", + "type": "string" + }, + "maxItems": 100, + "title": "Merged From", + "type": "array" + }, + "model_id": { + "maxLength": 200, + "minLength": 1, + "title": "Model Id", + "type": "string" + }, + "node_id": { + "maxLength": 128, + "minLength": 1, + "pattern": "^[a-zA-Z0-9][a-zA-Z0-9._:-]*$", + "title": "Node Id", + "type": "string" + }, + "node_type": { + "$ref": "#/components/schemas/KnowledgeNode__NodeType" + }, + "project_id": { + "maxLength": 128, + "minLength": 1, + "pattern": "^[a-zA-Z0-9][a-zA-Z0-9._:-]*$", + "title": "Project Id", + "type": "string" + }, + "reporting_year": { + "anyOf": [ + { + "maximum": 9999, + "minimum": 1, + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Reporting Year" + }, + "review_state": { + "$ref": "#/components/schemas/KnowledgeNode__ReviewState" + }, + "run_id": { + "maxLength": 128, + "minLength": 1, + "pattern": "^[a-zA-Z0-9][a-zA-Z0-9._:-]*$", + "title": "Run Id", + "type": "string" + }, + "sample_size": { + "anyOf": [ + { + "minimum": 0, + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Sample Size" + }, + "schema_version": { + "const": 1, + "default": 1, + "title": "Schema Version", + "type": "integer" + }, + "stale_after": { + "anyOf": [ + { + "format": "date-time", + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Stale After" + }, + "summary": { + "maxLength": 8000, + "minLength": 1, + "title": "Summary", + "type": "string" + }, + "superseded_by": { + "default": [], + "items": { + "maxLength": 128, + "minLength": 1, + "pattern": "^[a-zA-Z0-9][a-zA-Z0-9._:-]*$", + "type": "string" + }, + "title": "Superseded By", + "type": "array" + }, + "supersedes": { + "default": [], + "items": { + "maxLength": 128, + "minLength": 1, + "pattern": "^[a-zA-Z0-9][a-zA-Z0-9._:-]*$", + "type": "string" + }, + "title": "Supersedes", + "type": "array" + }, + "tags": { + "default": [], + "items": { + "type": "string" + }, + "maxItems": 100, + "title": "Tags", + "type": "array" + }, + "title": { + "maxLength": 300, + "minLength": 1, + "title": "Title", + "type": "string" + }, + "unit": { + "anyOf": [ + { + "maxLength": 100, + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Unit" + }, + "updated": { + "format": "date-time", + "title": "Updated", + "type": "string" + }, + "use_for": { + "default": [], + "items": { + "type": "string" + }, + "maxItems": 20, + "title": "Use For", + "type": "array" + }, + "value": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Value" + } + }, + "required": [ + "node_id", + "project_id", + "node_type", + "title", + "summary", + "created", + "updated", + "review_state", + "lifecycle", + "confidence", + "claim_support", + "run_id", + "agent_id", + "model_id", + "claims" + ], + "title": "KnowledgeNode", + "type": "object" + }, + "KnowledgeNode__Claim": { + "additionalProperties": false, + "properties": { + "assessment": { + "$ref": "#/components/schemas/KnowledgeNode__SupportAssessment" + }, + "claim_id": { + "maxLength": 128, + "minLength": 1, + "pattern": "^[a-zA-Z0-9][a-zA-Z0-9._:-]*$", + "title": "Claim Id", + "type": "string" + }, + "evidence": { + "items": { + "$ref": "#/components/schemas/KnowledgeNode__EvidenceReference" + }, + "maxItems": 20, + "minItems": 1, + "title": "Evidence", + "type": "array" + }, + "evidence_type": { + "enum": [ + "measured", + "reported", + "opinion", + "vendor_marketing" + ], + "title": "Evidence Type", + "type": "string" + }, + "schema_version": { + "const": 1, + "default": 1, + "title": "Schema Version", + "type": "integer" + }, + "text": { + "maxLength": 4000, + "minLength": 1, + "title": "Text", + "type": "string" + } + }, + "required": [ + "claim_id", + "text", + "evidence_type", + "evidence", + "assessment" + ], + "title": "Claim", + "type": "object" + }, + "KnowledgeNode__Confidence": { + "enum": [ + "low", + "medium", + "high" + ], + "title": "Confidence", + "type": "string" + }, + "KnowledgeNode__EvidenceReference": { + "additionalProperties": false, + "properties": { + "assertion_digest": { + "pattern": "^[a-f0-9]{64}$", + "title": "Assertion Digest", + "type": "string" + }, + "assertion_path": { + "maxLength": 256, + "minLength": 1, + "title": "Assertion Path", + "type": "string" + }, + "extraction_id": { + "pattern": "^[a-f0-9]{64}$", + "title": "Extraction Id", + "type": "string" + }, + "locator": { + "$ref": "#/components/schemas/KnowledgeNode__Locator" + }, + "normalization_profile": { + "const": "unicode-nfc-lf-v1", + "title": "Normalization Profile", + "type": "string" + }, + "quote": { + "maxLength": 8000, + "minLength": 1, + "title": "Quote", + "type": "string" + }, + "schema_version": { + "const": 1, + "default": 1, + "title": "Schema Version", + "type": "integer" + }, + "source_revision_id": { + "maxLength": 128, + "minLength": 1, + "pattern": "^[a-zA-Z0-9][a-zA-Z0-9._:-]*$", + "title": "Source Revision Id", + "type": "string" + } + }, + "required": [ + "source_revision_id", + "extraction_id", + "assertion_path", + "assertion_digest", + "quote", + "locator", + "normalization_profile" + ], + "title": "EvidenceReference", + "type": "object" + }, + "KnowledgeNode__LensFit": { + "additionalProperties": false, + "properties": { + "horizon": { + "enum": [ + "H1", + "H2", + "H3" + ], + "title": "Horizon", + "type": "string" + }, + "key_question": { + "anyOf": [ + { + "maxLength": 1000, + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Key Question" + }, + "reason": { + "maxLength": 1000, + "minLength": 1, + "title": "Reason", + "type": "string" + }, + "schema_version": { + "const": 1, + "default": 1, + "title": "Schema Version", + "type": "integer" + }, + "score": { + "maximum": 1, + "minimum": 0, + "title": "Score", + "type": "number" + } + }, + "required": [ + "score", + "horizon", + "reason" + ], + "title": "LensFit", + "type": "object" + }, + "KnowledgeNode__Lifecycle": { + "enum": [ + "active", + "superseded" + ], + "title": "Lifecycle", + "type": "string" + }, + "KnowledgeNode__Locator": { + "additionalProperties": false, + "properties": { + "block_id": { + "maxLength": 128, + "minLength": 1, + "pattern": "^[a-zA-Z0-9][a-zA-Z0-9._:-]*$", + "title": "Block Id", + "type": "string" + }, + "end": { + "exclusiveMinimum": 0, + "title": "End", + "type": "integer" + }, + "kind": { + "enum": [ + "text_span", + "pdf_page_span", + "transcript_timestamp_span" + ], + "title": "Kind", + "type": "string" + }, + "page": { + "anyOf": [ + { + "minimum": 1, + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Page" + }, + "schema_version": { + "const": 1, + "default": 1, + "title": "Schema Version", + "type": "integer" + }, + "start": { + "minimum": 0, + "title": "Start", + "type": "integer" + }, + "time_end_ms": { + "anyOf": [ + { + "minimum": 0, + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Time End Ms" + }, + "time_start_ms": { + "anyOf": [ + { + "minimum": 0, + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Time Start Ms" + } + }, + "required": [ + "kind", + "block_id", + "start", + "end" + ], + "title": "Locator", + "type": "object" + }, + "KnowledgeNode__NodeType": { + "enum": [ + "finding", + "problem", + "technique", + "case", + "entity", + "event", + "concept", + "datapoint", + "synthesis" + ], + "title": "NodeType", + "type": "string" + }, + "KnowledgeNode__ReviewState": { + "enum": [ + "draft", + "reviewed" + ], + "title": "ReviewState", + "type": "string" + }, + "KnowledgeNode__SupportAssessment": { + "additionalProperties": false, + "properties": { + "assertion_digest": { + "pattern": "^[a-f0-9]{64}$", + "title": "Assertion Digest", + "type": "string" + }, + "assessment_id": { + "maxLength": 128, + "minLength": 1, + "pattern": "^[a-zA-Z0-9][a-zA-Z0-9._:-]*$", + "title": "Assessment Id", + "type": "string" + }, + "assessor_id": { + "maxLength": 128, + "minLength": 1, + "pattern": "^[a-zA-Z0-9][a-zA-Z0-9._:-]*$", + "title": "Assessor Id", + "type": "string" + }, + "evidence_digests": { + "items": { + "pattern": "^[a-f0-9]{64}$", + "type": "string" + }, + "maxItems": 20, + "minItems": 1, + "title": "Evidence Digests", + "type": "array" + }, + "method": { + "maxLength": 100, + "minLength": 1, + "title": "Method", + "type": "string" + }, + "rationale": { + "maxLength": 4000, + "minLength": 1, + "title": "Rationale", + "type": "string" + }, + "run_id": { + "maxLength": 128, + "minLength": 1, + "pattern": "^[a-zA-Z0-9][a-zA-Z0-9._:-]*$", + "title": "Run Id", + "type": "string" + }, + "schema_version": { + "const": 1, + "default": 1, + "title": "Schema Version", + "type": "integer" + }, + "status": { + "$ref": "#/components/schemas/KnowledgeNode__SupportStatus" + } + }, + "required": [ + "assessment_id", + "assertion_digest", + "evidence_digests", + "assessor_id", + "run_id", + "method", + "status", + "rationale" + ], + "title": "SupportAssessment", + "type": "object" + }, + "KnowledgeNode__SupportStatus": { + "enum": [ + "supported", + "unsupported", + "unavailable" + ], + "title": "SupportStatus", + "type": "string" + }, + "Locator": { + "additionalProperties": false, + "properties": { + "block_id": { + "maxLength": 128, + "minLength": 1, + "pattern": "^[a-zA-Z0-9][a-zA-Z0-9._:-]*$", + "title": "Block Id", + "type": "string" + }, + "end": { + "exclusiveMinimum": 0.0, + "title": "End", + "type": "integer" + }, + "kind": { + "enum": [ + "text_span", + "pdf_page_span", + "transcript_timestamp_span" + ], + "title": "Kind", + "type": "string" + }, + "page": { + "anyOf": [ + { + "minimum": 1.0, + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Page" + }, + "schema_version": { + "const": 1, + "default": 1, + "title": "Schema Version", + "type": "integer" + }, + "start": { + "minimum": 0.0, + "title": "Start", + "type": "integer" + }, + "time_end_ms": { + "anyOf": [ + { + "minimum": 0.0, + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Time End Ms" + }, + "time_start_ms": { + "anyOf": [ + { + "minimum": 0.0, + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Time Start Ms" + } + }, + "required": [ + "kind", + "block_id", + "start", + "end" + ], + "title": "Locator", + "type": "object" + }, + "LoginBody": { + "additionalProperties": false, + "properties": { + "password": { + "maxLength": 1024, + "minLength": 1, + "title": "Password", + "type": "string" + }, + "username": { + "pattern": "^[a-z0-9][a-z0-9._-]{0,63}$", + "title": "Username", + "type": "string" + } + }, + "required": [ + "username", + "password" + ], + "title": "LoginBody", + "type": "object" + }, + "NeighborRequest": { + "additionalProperties": false, + "properties": { + "budget_tokens": { + "maximum": 50000, + "minimum": 256, + "title": "Budget Tokens", + "type": "integer" + }, + "cursor": { + "anyOf": [ + { + "maxLength": 512, + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Cursor" + }, + "edge_types": { + "default": [], + "items": { + "enum": [ + "solves", + "addresses", + "causes", + "supports", + "contradicts", + "instance_of", + "part_of", + "precedes", + "supersedes", + "compared_with", + "derived_from", + "applies_to" + ], + "type": "string" + }, + "maxItems": 12, + "title": "Edge Types", + "type": "array" + }, + "limit": { + "default": 20, + "maximum": 50, + "minimum": 1, + "title": "Limit", + "type": "integer" + }, + "node_id": { + "maxLength": 128, + "minLength": 1, + "pattern": "^[a-zA-Z0-9][a-zA-Z0-9._:-]*$", + "title": "Node Id", + "type": "string" + }, + "schema_version": { + "const": 1, + "default": 1, + "title": "Schema Version", + "type": "integer" + }, + "scope": { + "$ref": "#/components/schemas/NeighborRequest__Scope" + } + }, + "required": [ + "node_id", + "scope", + "budget_tokens" + ], + "title": "NeighborRequest", + "type": "object" + }, + "NeighborRequestBody": { + "additionalProperties": false, + "properties": { + "budget_tokens": { + "maximum": 50000.0, + "minimum": 256.0, + "title": "Budget Tokens", + "type": "integer" + }, + "cursor": { + "anyOf": [ + { + "maxLength": 512, + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Cursor" + }, + "edge_types": { + "items": { + "type": "string" + }, + "maxItems": 12, + "title": "Edge Types", + "type": "array" + }, + "limit": { + "default": 20, + "maximum": 50.0, + "minimum": 1.0, + "title": "Limit", + "type": "integer" + }, + "node_id": { + "maxLength": 128, + "minLength": 1, + "pattern": "^[a-zA-Z0-9][a-zA-Z0-9._:-]*$", + "title": "Node Id", + "type": "string" + }, + "scope": { + "$ref": "#/components/schemas/SearchScopeBody" + } + }, + "required": [ + "node_id", + "scope", + "budget_tokens" + ], + "title": "NeighborRequestBody", + "type": "object" + }, + "NeighborRequest__Scope": { + "additionalProperties": false, + "description": "Project context plus optional project includes; all-project access is deliberate.", + "properties": { + "all_projects": { + "default": false, + "title": "All Projects", + "type": "boolean" + }, + "include_projects": { + "anyOf": [ + { + "items": { + "maxLength": 128, + "minLength": 1, + "pattern": "^[a-zA-Z0-9][a-zA-Z0-9._:-]*$", + "type": "string" + }, + "maxItems": 50, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Include Projects" + }, + "include_shared": { + "default": true, + "title": "Include Shared", + "type": "boolean" + }, + "projects": { + "items": { + "maxLength": 128, + "minLength": 1, + "pattern": "^[a-zA-Z0-9][a-zA-Z0-9._:-]*$", + "type": "string" + }, + "maxItems": 50, + "minItems": 1, + "title": "Projects", + "type": "array" + }, + "schema_version": { + "const": 1, + "default": 1, + "title": "Schema Version", + "type": "integer" + } + }, + "required": [ + "projects" + ], + "title": "Scope", + "type": "object" + }, + "OwnerSessionRequestBody": { + "additionalProperties": false, + "description": "Short-lived owner authentication inputs; never accepted as a normal session.", + "properties": { + "challenge_id": { + "maxLength": 128, + "minLength": 1, + "pattern": "^[a-zA-Z0-9][a-zA-Z0-9._:-]*$", + "title": "Challenge Id", + "type": "string" + }, + "password": { + "maxLength": 1024, + "minLength": 12, + "title": "Password", + "type": "string" + }, + "principal_id": { + "maxLength": 128, + "minLength": 1, + "pattern": "^[a-zA-Z0-9][a-zA-Z0-9._:-]*$", + "title": "Principal Id", + "type": "string" + }, + "totp_code": { + "pattern": "^\\d{6}$", + "title": "Totp Code", + "type": "string" + } + }, + "required": [ + "principal_id", + "password", + "totp_code", + "challenge_id" + ], + "title": "OwnerSessionRequestBody", + "type": "object" + }, + "PasswordChangeBody": { + "additionalProperties": false, + "properties": { + "current_password": { + "maxLength": 1024, + "minLength": 1, + "title": "Current Password", + "type": "string" + }, + "new_password": { + "maxLength": 1024, + "minLength": 12, + "title": "New Password", + "type": "string" + } + }, + "required": [ + "current_password", + "new_password" + ], + "title": "PasswordChangeBody", + "type": "object" + }, + "ProjectContentUpdateBody": { + "additionalProperties": false, + "description": "Editable human-authored project intent fields.", + "properties": { + "description": { + "maxLength": 4000, + "title": "Description", + "type": "string" + }, + "key_questions": { + "items": { + "type": "string" + }, + "maxItems": 30, + "minItems": 1, + "title": "Key Questions", + "type": "array" + }, + "north_star": { + "maxLength": 4000, + "minLength": 1, + "title": "North Star", + "type": "string" + } + }, + "required": [ + "description", + "north_star", + "key_questions" + ], + "title": "ProjectContentUpdateBody", + "type": "object" + }, + "ProjectCreateBody": { + "additionalProperties": false, + "description": "An administrator supplies the human-owned project lens and its initial policy.", + "properties": { + "policy": { + "$ref": "#/components/schemas/AutoAcceptPolicy" + }, + "project": { + "$ref": "#/components/schemas/ProjectRecord" + } + }, + "required": [ + "project", + "policy" + ], + "title": "ProjectCreateBody", + "type": "object" + }, + "ProjectRecord": { + "additionalProperties": false, + "properties": { + "autonomy_policy_id": { + "maxLength": 128, + "minLength": 1, + "pattern": "^[a-zA-Z0-9][a-zA-Z0-9._:-]*$", + "title": "Autonomy Policy Id", + "type": "string" + }, + "default_includes": { + "default": [ + "shared" + ], + "items": { + "maxLength": 128, + "minLength": 1, + "pattern": "^[a-zA-Z0-9][a-zA-Z0-9._:-]*$", + "type": "string" + }, + "title": "Default Includes", + "type": "array" + }, + "description": { + "default": "", + "maxLength": 4000, + "title": "Description", + "type": "string" + }, + "key_questions": { + "items": { + "type": "string" + }, + "maxItems": 30, + "minItems": 1, + "title": "Key Questions", + "type": "array" + }, + "north_star": { + "maxLength": 4000, + "minLength": 1, + "title": "North Star", + "type": "string" + }, + "owners": { + "default": [], + "items": { + "maxLength": 128, + "minLength": 1, + "pattern": "^[a-zA-Z0-9][a-zA-Z0-9._:-]*$", + "type": "string" + }, + "title": "Owners", + "type": "array" + }, + "project_id": { + "maxLength": 128, + "minLength": 1, + "pattern": "^[a-zA-Z0-9][a-zA-Z0-9._:-]*$", + "title": "Project Id", + "type": "string" + }, + "schema_version": { + "const": 1, + "default": 1, + "title": "Schema Version", + "type": "integer" + }, + "status": { + "$ref": "#/components/schemas/ProjectStatus" + }, + "taxonomy_extensions": { + "additionalProperties": { + "items": { + "type": "string" + }, + "type": "array" + }, + "maxProperties": 100, + "title": "Taxonomy Extensions", + "type": "object" + }, + "title": { + "maxLength": 200, + "minLength": 1, + "title": "Title", + "type": "string" + }, + "watch_list": { + "default": [], + "items": { + "type": "string" + }, + "maxItems": 100, + "title": "Watch List", + "type": "array" + } + }, + "required": [ + "project_id", + "title", + "status", + "north_star", + "key_questions", + "autonomy_policy_id" + ], + "title": "ProjectRecord", + "type": "object" + }, + "ProjectRecord__ProjectStatus": { + "enum": [ + "active", + "paused", + "archived" + ], + "title": "ProjectStatus", + "type": "string" + }, + "ProjectStatus": { + "enum": [ + "active", + "paused", + "archived" + ], + "title": "ProjectStatus", + "type": "string" + }, + "ProposalReceipt": { + "additionalProperties": false, + "properties": { + "accepted_node_ids": { + "default": [], + "items": { + "maxLength": 128, + "minLength": 1, + "pattern": "^[a-zA-Z0-9][a-zA-Z0-9._:-]*$", + "type": "string" + }, + "title": "Accepted Node Ids", + "type": "array" + }, + "commit_id": { + "anyOf": [ + { + "pattern": "^(?:[a-f0-9]{40}|[a-f0-9]{64})$", + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Commit Id" + }, + "decided_at": { + "anyOf": [ + { + "format": "date-time", + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Decided At" + }, + "findings": { + "default": [], + "items": { + "$ref": "#/components/schemas/ProposalReceipt__Finding" + }, + "title": "Findings", + "type": "array" + }, + "outcome": { + "enum": [ + "accepted", + "held", + "rejected", + "validation-failed" + ], + "title": "Outcome", + "type": "string" + }, + "payload_digest": { + "pattern": "^[a-f0-9]{64}$", + "title": "Payload Digest", + "type": "string" + }, + "proposal_id": { + "maxLength": 128, + "minLength": 1, + "pattern": "^[a-zA-Z0-9][a-zA-Z0-9._:-]*$", + "title": "Proposal Id", + "type": "string" + }, + "revision": { + "minimum": 1, + "title": "Revision", + "type": "integer" + }, + "schema_version": { + "const": 1, + "default": 1, + "title": "Schema Version", + "type": "integer" + }, + "state": { + "$ref": "#/components/schemas/ProposalReceipt__ProposalState" + } + }, + "required": [ + "proposal_id", + "revision", + "payload_digest", + "state", + "outcome" + ], + "title": "ProposalReceipt", + "type": "object" + }, + "ProposalReceipt__Finding": { + "additionalProperties": false, + "properties": { + "code": { + "maxLength": 128, + "minLength": 1, + "pattern": "^[a-zA-Z0-9][a-zA-Z0-9._:-]*$", + "title": "Code", + "type": "string" + }, + "message": { + "maxLength": 1000, + "minLength": 1, + "title": "Message", + "type": "string" + }, + "path": { + "anyOf": [ + { + "maxLength": 256, + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Path" + }, + "schema_version": { + "const": 1, + "default": 1, + "title": "Schema Version", + "type": "integer" + } + }, + "required": [ + "code", + "message" + ], + "title": "Finding", + "type": "object" + }, + "ProposalReceipt__ProposalState": { + "enum": [ + "staged", + "validating", + "needs-review", + "accepted", + "rejected", + "withdrawn" + ], + "title": "ProposalState", + "type": "string" + }, + "ProposalRevision": { + "additionalProperties": false, + "properties": { + "base_commit": { + "pattern": "^(?:[a-f0-9]{40}|[a-f0-9]{64})$", + "title": "Base Commit", + "type": "string" + }, + "initiating_project_id": { + "maxLength": 128, + "minLength": 1, + "pattern": "^[a-zA-Z0-9][a-zA-Z0-9._:-]*$", + "title": "Initiating Project Id", + "type": "string" + }, + "operations": { + "items": { + "$ref": "#/components/schemas/ProposalRevision__ProposalOperation" + }, + "maxItems": 100, + "minItems": 1, + "title": "Operations", + "type": "array" + }, + "payload_digest": { + "pattern": "^[a-f0-9]{64}$", + "title": "Payload Digest", + "type": "string" + }, + "proposal_id": { + "maxLength": 128, + "minLength": 1, + "pattern": "^[a-zA-Z0-9][a-zA-Z0-9._:-]*$", + "title": "Proposal Id", + "type": "string" + }, + "proposer_id": { + "maxLength": 128, + "minLength": 1, + "pattern": "^[a-zA-Z0-9][a-zA-Z0-9._:-]*$", + "title": "Proposer Id", + "type": "string" + }, + "revision": { + "minimum": 1, + "title": "Revision", + "type": "integer" + }, + "run_id": { + "maxLength": 128, + "minLength": 1, + "pattern": "^[a-zA-Z0-9][a-zA-Z0-9._:-]*$", + "title": "Run Id", + "type": "string" + }, + "schema_version": { + "const": 1, + "default": 1, + "title": "Schema Version", + "type": "integer" + }, + "scope": { + "$ref": "#/components/schemas/ProposalRevision__Scope" + } + }, + "required": [ + "proposal_id", + "revision", + "initiating_project_id", + "scope", + "base_commit", + "payload_digest", + "proposer_id", + "run_id", + "operations" + ], + "title": "ProposalRevision", + "type": "object" + }, + "ProposalRevision__Claim": { + "additionalProperties": false, + "properties": { + "assessment": { + "$ref": "#/components/schemas/ProposalRevision__SupportAssessment" + }, + "claim_id": { + "maxLength": 128, + "minLength": 1, + "pattern": "^[a-zA-Z0-9][a-zA-Z0-9._:-]*$", + "title": "Claim Id", + "type": "string" + }, + "evidence": { + "items": { + "$ref": "#/components/schemas/ProposalRevision__EvidenceReference" + }, + "maxItems": 20, + "minItems": 1, + "title": "Evidence", + "type": "array" + }, + "evidence_type": { + "enum": [ + "measured", + "reported", + "opinion", + "vendor_marketing" + ], + "title": "Evidence Type", + "type": "string" + }, + "schema_version": { + "const": 1, + "default": 1, + "title": "Schema Version", + "type": "integer" + }, + "text": { + "maxLength": 4000, + "minLength": 1, + "title": "Text", + "type": "string" + } + }, + "required": [ + "claim_id", + "text", + "evidence_type", + "evidence", + "assessment" + ], + "title": "Claim", + "type": "object" + }, + "ProposalRevision__Confidence": { + "enum": [ + "low", + "medium", + "high" + ], + "title": "Confidence", + "type": "string" + }, + "ProposalRevision__EdgeEndpoint": { + "additionalProperties": false, + "properties": { + "id": { + "maxLength": 128, + "minLength": 1, + "pattern": "^[a-zA-Z0-9][a-zA-Z0-9._:-]*$", + "title": "Id", + "type": "string" + }, + "kind": { + "enum": [ + "node", + "source", + "project" + ], + "title": "Kind", + "type": "string" + }, + "schema_version": { + "const": 1, + "default": 1, + "title": "Schema Version", + "type": "integer" + } + }, + "required": [ + "kind", + "id" + ], + "title": "EdgeEndpoint", + "type": "object" + }, + "ProposalRevision__EvidenceReference": { + "additionalProperties": false, + "properties": { + "assertion_digest": { + "pattern": "^[a-f0-9]{64}$", + "title": "Assertion Digest", + "type": "string" + }, + "assertion_path": { + "maxLength": 256, + "minLength": 1, + "title": "Assertion Path", + "type": "string" + }, + "extraction_id": { + "pattern": "^[a-f0-9]{64}$", + "title": "Extraction Id", + "type": "string" + }, + "locator": { + "$ref": "#/components/schemas/ProposalRevision__Locator" + }, + "normalization_profile": { + "const": "unicode-nfc-lf-v1", + "title": "Normalization Profile", + "type": "string" + }, + "quote": { + "maxLength": 8000, + "minLength": 1, + "title": "Quote", + "type": "string" + }, + "schema_version": { + "const": 1, + "default": 1, + "title": "Schema Version", + "type": "integer" + }, + "source_revision_id": { + "maxLength": 128, + "minLength": 1, + "pattern": "^[a-zA-Z0-9][a-zA-Z0-9._:-]*$", + "title": "Source Revision Id", + "type": "string" + } + }, + "required": [ + "source_revision_id", + "extraction_id", + "assertion_path", + "assertion_digest", + "quote", + "locator", + "normalization_profile" + ], + "title": "EvidenceReference", + "type": "object" + }, + "ProposalRevision__KnowledgeNode": { + "additionalProperties": false, + "properties": { + "agent_id": { + "maxLength": 128, + "minLength": 1, + "pattern": "^[a-zA-Z0-9][a-zA-Z0-9._:-]*$", + "title": "Agent Id", + "type": "string" + }, + "aliases": { + "default": [], + "items": { + "type": "string" + }, + "maxItems": 100, + "title": "Aliases", + "type": "array" + }, + "as_of": { + "anyOf": [ + { + "format": "date-time", + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "As Of" + }, + "assumptions": { + "default": [], + "items": { + "$ref": "#/components/schemas/ProposalRevision__Claim" + }, + "maxItems": 100, + "title": "Assumptions", + "type": "array" + }, + "audience": { + "default": [], + "items": { + "type": "string" + }, + "maxItems": 20, + "title": "Audience", + "type": "array" + }, + "claim_support": { + "enum": [ + "pending", + "supported", + "unsupported" + ], + "title": "Claim Support", + "type": "string" + }, + "claims": { + "items": { + "$ref": "#/components/schemas/ProposalRevision__Claim" + }, + "maxItems": 100, + "minItems": 1, + "title": "Claims", + "type": "array" + }, + "confidence": { + "$ref": "#/components/schemas/ProposalRevision__Confidence" + }, + "created": { + "format": "date-time", + "title": "Created", + "type": "string" + }, + "demographic": { + "anyOf": [ + { + "maxLength": 1000, + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Demographic" + }, + "domain": { + "default": [], + "items": { + "type": "string" + }, + "maxItems": 20, + "title": "Domain", + "type": "array" + }, + "lens_fit": { + "maxProperties": 50, + "patternProperties": { + "^[a-zA-Z0-9][a-zA-Z0-9._:-]*$": { + "$ref": "#/components/schemas/ProposalRevision__LensFit" + } + }, + "propertyNames": { + "maxLength": 128, + "minLength": 1 + }, + "title": "Lens Fit", + "type": "object" + }, + "lifecycle": { + "$ref": "#/components/schemas/ProposalRevision__Lifecycle" + }, + "limitations": { + "default": [], + "items": { + "$ref": "#/components/schemas/ProposalRevision__Claim" + }, + "maxItems": 100, + "title": "Limitations", + "type": "array" + }, + "merged_from": { + "default": [], + "items": { + "maxLength": 128, + "minLength": 1, + "pattern": "^[a-zA-Z0-9][a-zA-Z0-9._:-]*$", + "type": "string" + }, + "maxItems": 100, + "title": "Merged From", + "type": "array" + }, + "model_id": { + "maxLength": 200, + "minLength": 1, + "title": "Model Id", + "type": "string" + }, + "node_id": { + "maxLength": 128, + "minLength": 1, + "pattern": "^[a-zA-Z0-9][a-zA-Z0-9._:-]*$", + "title": "Node Id", + "type": "string" + }, + "node_type": { + "$ref": "#/components/schemas/ProposalRevision__NodeType" + }, + "project_id": { + "maxLength": 128, + "minLength": 1, + "pattern": "^[a-zA-Z0-9][a-zA-Z0-9._:-]*$", + "title": "Project Id", + "type": "string" + }, + "reporting_year": { + "anyOf": [ + { + "maximum": 9999, + "minimum": 1, + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Reporting Year" + }, + "review_state": { + "$ref": "#/components/schemas/ProposalRevision__ReviewState" + }, + "run_id": { + "maxLength": 128, + "minLength": 1, + "pattern": "^[a-zA-Z0-9][a-zA-Z0-9._:-]*$", + "title": "Run Id", + "type": "string" + }, + "sample_size": { + "anyOf": [ + { + "minimum": 0, + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Sample Size" + }, + "schema_version": { + "const": 1, + "default": 1, + "title": "Schema Version", + "type": "integer" + }, + "stale_after": { + "anyOf": [ + { + "format": "date-time", + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Stale After" + }, + "summary": { + "maxLength": 8000, + "minLength": 1, + "title": "Summary", + "type": "string" + }, + "superseded_by": { + "default": [], + "items": { + "maxLength": 128, + "minLength": 1, + "pattern": "^[a-zA-Z0-9][a-zA-Z0-9._:-]*$", + "type": "string" + }, + "title": "Superseded By", + "type": "array" + }, + "supersedes": { + "default": [], + "items": { + "maxLength": 128, + "minLength": 1, + "pattern": "^[a-zA-Z0-9][a-zA-Z0-9._:-]*$", + "type": "string" + }, + "title": "Supersedes", + "type": "array" + }, + "tags": { + "default": [], + "items": { + "type": "string" + }, + "maxItems": 100, + "title": "Tags", + "type": "array" + }, + "title": { + "maxLength": 300, + "minLength": 1, + "title": "Title", + "type": "string" + }, + "unit": { + "anyOf": [ + { + "maxLength": 100, + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Unit" + }, + "updated": { + "format": "date-time", + "title": "Updated", + "type": "string" + }, + "use_for": { + "default": [], + "items": { + "type": "string" + }, + "maxItems": 20, + "title": "Use For", + "type": "array" + }, + "value": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Value" + } + }, + "required": [ + "node_id", + "project_id", + "node_type", + "title", + "summary", + "created", + "updated", + "review_state", + "lifecycle", + "confidence", + "claim_support", + "run_id", + "agent_id", + "model_id", + "claims" + ], + "title": "KnowledgeNode", + "type": "object" + }, + "ProposalRevision__LensFit": { + "additionalProperties": false, + "properties": { + "horizon": { + "enum": [ + "H1", + "H2", + "H3" + ], + "title": "Horizon", + "type": "string" + }, + "key_question": { + "anyOf": [ + { + "maxLength": 1000, + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Key Question" + }, + "reason": { + "maxLength": 1000, + "minLength": 1, + "title": "Reason", + "type": "string" + }, + "schema_version": { + "const": 1, + "default": 1, + "title": "Schema Version", + "type": "integer" + }, + "score": { + "maximum": 1, + "minimum": 0, + "title": "Score", + "type": "number" + } + }, + "required": [ + "score", + "horizon", + "reason" + ], + "title": "LensFit", + "type": "object" + }, + "ProposalRevision__Lifecycle": { + "enum": [ + "active", + "superseded" + ], + "title": "Lifecycle", + "type": "string" + }, + "ProposalRevision__Locator": { + "additionalProperties": false, + "properties": { + "block_id": { + "maxLength": 128, + "minLength": 1, + "pattern": "^[a-zA-Z0-9][a-zA-Z0-9._:-]*$", + "title": "Block Id", + "type": "string" + }, + "end": { + "exclusiveMinimum": 0, + "title": "End", + "type": "integer" + }, + "kind": { + "enum": [ + "text_span", + "pdf_page_span", + "transcript_timestamp_span" + ], + "title": "Kind", + "type": "string" + }, + "page": { + "anyOf": [ + { + "minimum": 1, + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Page" + }, + "schema_version": { + "const": 1, + "default": 1, + "title": "Schema Version", + "type": "integer" + }, + "start": { + "minimum": 0, + "title": "Start", + "type": "integer" + }, + "time_end_ms": { + "anyOf": [ + { + "minimum": 0, + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Time End Ms" + }, + "time_start_ms": { + "anyOf": [ + { + "minimum": 0, + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Time Start Ms" + } + }, + "required": [ + "kind", + "block_id", + "start", + "end" + ], + "title": "Locator", + "type": "object" + }, + "ProposalRevision__NodeType": { + "enum": [ + "finding", + "problem", + "technique", + "case", + "entity", + "event", + "concept", + "datapoint", + "synthesis" + ], + "title": "NodeType", + "type": "string" + }, + "ProposalRevision__ProposalOperation": { + "additionalProperties": false, + "properties": { + "edge": { + "anyOf": [ + { + "$ref": "#/components/schemas/ProposalRevision__TypedEdge" + }, + { + "type": "null" + } + ], + "default": null + }, + "node": { + "anyOf": [ + { + "$ref": "#/components/schemas/ProposalRevision__KnowledgeNode" + }, + { + "type": "null" + } + ], + "default": null + }, + "operation": { + "enum": [ + "register_source", + "upsert_node", + "add_edge", + "flag_stale" + ], + "title": "Operation", + "type": "string" + }, + "schema_version": { + "const": 1, + "default": 1, + "title": "Schema Version", + "type": "integer" + }, + "source": { + "anyOf": [ + { + "$ref": "#/components/schemas/ProposalRevision__SourceRevisionRecord" + }, + { + "type": "null" + } + ], + "default": null + }, + "target_id": { + "anyOf": [ + { + "maxLength": 128, + "minLength": 1, + "pattern": "^[a-zA-Z0-9][a-zA-Z0-9._:-]*$", + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Target Id" + } + }, + "required": [ + "operation" + ], + "title": "ProposalOperation", + "type": "object" + }, + "ProposalRevision__ReliabilityGrade": { + "enum": [ + "low", + "medium", + "high" + ], + "title": "ReliabilityGrade", + "type": "string" + }, + "ProposalRevision__ReviewState": { + "enum": [ + "draft", + "reviewed" + ], + "title": "ReviewState", + "type": "string" + }, + "ProposalRevision__Scope": { + "additionalProperties": false, + "description": "Project context plus optional project includes; all-project access is deliberate.", + "properties": { + "all_projects": { + "default": false, + "title": "All Projects", + "type": "boolean" + }, + "include_projects": { + "anyOf": [ + { + "items": { + "maxLength": 128, + "minLength": 1, + "pattern": "^[a-zA-Z0-9][a-zA-Z0-9._:-]*$", + "type": "string" + }, + "maxItems": 50, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Include Projects" + }, + "include_shared": { + "default": true, + "title": "Include Shared", + "type": "boolean" + }, + "projects": { + "items": { + "maxLength": 128, + "minLength": 1, + "pattern": "^[a-zA-Z0-9][a-zA-Z0-9._:-]*$", + "type": "string" + }, + "maxItems": 50, + "minItems": 1, + "title": "Projects", + "type": "array" + }, + "schema_version": { + "const": 1, + "default": 1, + "title": "Schema Version", + "type": "integer" + } + }, + "required": [ + "projects" + ], + "title": "Scope", + "type": "object" + }, + "ProposalRevision__SourceRevisionRecord": { + "additionalProperties": false, + "properties": { + "archive_status": { + "enum": [ + "archived", + "reference-only", + "capture-failed" + ], + "title": "Archive Status", + "type": "string" + }, + "artifact_sha256": { + "anyOf": [ + { + "pattern": "^[a-f0-9]{64}$", + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Artifact Sha256" + }, + "authors": { + "default": [], + "items": { + "type": "string" + }, + "maxItems": 50, + "title": "Authors", + "type": "array" + }, + "byte_length": { + "minimum": 0, + "title": "Byte Length", + "type": "integer" + }, + "capture_method": { + "default": "unspecified", + "enum": [ + "unspecified", + "local-file", + "url-fetch", + "pasted-text", + "notebooklm-export", + "transcript" + ], + "title": "Capture Method", + "type": "string" + }, + "captured_at": { + "format": "date-time", + "title": "Captured At", + "type": "string" + }, + "final_url": { + "anyOf": [ + { + "maxLength": 4000, + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Final Url" + }, + "ingested_for": { + "items": { + "maxLength": 128, + "minLength": 1, + "pattern": "^[a-zA-Z0-9][a-zA-Z0-9._:-]*$", + "type": "string" + }, + "maxItems": 50, + "minItems": 1, + "title": "Ingested For", + "type": "array" + }, + "license": { + "maxLength": 500, + "minLength": 1, + "title": "License", + "type": "string" + }, + "media_type": { + "maxLength": 128, + "minLength": 1, + "title": "Media Type", + "type": "string" + }, + "original_url": { + "anyOf": [ + { + "maxLength": 4000, + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Original Url" + }, + "publication_date": { + "anyOf": [ + { + "format": "date-time", + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Publication Date" + }, + "publisher": { + "anyOf": [ + { + "maxLength": 300, + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Publisher" + }, + "reliability_grade": { + "$ref": "#/components/schemas/ProposalRevision__ReliabilityGrade" + }, + "revision_id": { + "maxLength": 128, + "minLength": 1, + "pattern": "^[a-zA-Z0-9][a-zA-Z0-9._:-]*$", + "title": "Revision Id", + "type": "string" + }, + "schema_version": { + "const": 1, + "default": 1, + "title": "Schema Version", + "type": "integer" + }, + "source_id": { + "maxLength": 128, + "minLength": 1, + "pattern": "^[a-zA-Z0-9][a-zA-Z0-9._:-]*$", + "title": "Source Id", + "type": "string" + }, + "source_type": { + "enum": [ + "paper", + "review", + "press", + "vendor", + "internal", + "talk", + "export", + "other" + ], + "title": "Source Type", + "type": "string" + }, + "title": { + "maxLength": 500, + "minLength": 1, + "title": "Title", + "type": "string" + } + }, + "required": [ + "source_id", + "revision_id", + "media_type", + "byte_length", + "captured_at", + "title", + "license", + "source_type", + "reliability_grade", + "ingested_for", + "archive_status" + ], + "title": "SourceRevisionRecord", + "type": "object" + }, + "ProposalRevision__SupportAssessment": { + "additionalProperties": false, + "properties": { + "assertion_digest": { + "pattern": "^[a-f0-9]{64}$", + "title": "Assertion Digest", + "type": "string" + }, + "assessment_id": { + "maxLength": 128, + "minLength": 1, + "pattern": "^[a-zA-Z0-9][a-zA-Z0-9._:-]*$", + "title": "Assessment Id", + "type": "string" + }, + "assessor_id": { + "maxLength": 128, + "minLength": 1, + "pattern": "^[a-zA-Z0-9][a-zA-Z0-9._:-]*$", + "title": "Assessor Id", + "type": "string" + }, + "evidence_digests": { + "items": { + "pattern": "^[a-f0-9]{64}$", + "type": "string" + }, + "maxItems": 20, + "minItems": 1, + "title": "Evidence Digests", + "type": "array" + }, + "method": { + "maxLength": 100, + "minLength": 1, + "title": "Method", + "type": "string" + }, + "rationale": { + "maxLength": 4000, + "minLength": 1, + "title": "Rationale", + "type": "string" + }, + "run_id": { + "maxLength": 128, + "minLength": 1, + "pattern": "^[a-zA-Z0-9][a-zA-Z0-9._:-]*$", + "title": "Run Id", + "type": "string" + }, + "schema_version": { + "const": 1, + "default": 1, + "title": "Schema Version", + "type": "integer" + }, + "status": { + "$ref": "#/components/schemas/ProposalRevision__SupportStatus" + } + }, + "required": [ + "assessment_id", + "assertion_digest", + "evidence_digests", + "assessor_id", + "run_id", + "method", + "status", + "rationale" + ], + "title": "SupportAssessment", + "type": "object" + }, + "ProposalRevision__SupportStatus": { + "enum": [ + "supported", + "unsupported", + "unavailable" + ], + "title": "SupportStatus", + "type": "string" + }, + "ProposalRevision__TypedEdge": { + "additionalProperties": false, + "properties": { + "conditions": { + "anyOf": [ + { + "maxLength": 2000, + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Conditions" + }, + "edge_id": { + "maxLength": 128, + "minLength": 1, + "pattern": "^[a-zA-Z0-9][a-zA-Z0-9._:-]*$", + "title": "Edge Id", + "type": "string" + }, + "edge_type": { + "enum": [ + "solves", + "addresses", + "causes", + "supports", + "contradicts", + "instance_of", + "part_of", + "precedes", + "supersedes", + "compared_with", + "derived_from", + "applies_to" + ], + "title": "Edge Type", + "type": "string" + }, + "evidence": { + "default": [], + "items": { + "$ref": "#/components/schemas/ProposalRevision__EvidenceReference" + }, + "title": "Evidence", + "type": "array" + }, + "interpretation_rationale": { + "anyOf": [ + { + "maxLength": 4000, + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Interpretation Rationale" + }, + "schema_version": { + "const": 1, + "default": 1, + "title": "Schema Version", + "type": "integer" + }, + "source": { + "$ref": "#/components/schemas/ProposalRevision__EdgeEndpoint" + }, + "target": { + "$ref": "#/components/schemas/ProposalRevision__EdgeEndpoint" + } + }, + "required": [ + "edge_id", + "edge_type", + "source", + "target" + ], + "title": "TypedEdge", + "type": "object" + }, + "ProposalStatus": { + "additionalProperties": false, + "properties": { + "canonical_revision": { + "anyOf": [ + { + "pattern": "^(?:[a-f0-9]{40}|[a-f0-9]{64})$", + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Canonical Revision" + }, + "eligible_for_auto_accept": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Eligible For Auto Accept" + }, + "findings": { + "default": [], + "items": { + "$ref": "#/components/schemas/ProposalStatus__Finding" + }, + "maxItems": 200, + "title": "Findings", + "type": "array" + }, + "lifecycle": { + "enum": [ + "staged", + "evaluated", + "publishing", + "published-unreconciled", + "accepted", + "withdrawn" + ], + "title": "Lifecycle", + "type": "string" + }, + "proposal_id": { + "maxLength": 128, + "minLength": 1, + "pattern": "^[a-zA-Z0-9][a-zA-Z0-9._:-]*$", + "title": "Proposal Id", + "type": "string" + }, + "receipt": { + "anyOf": [ + { + "$ref": "#/components/schemas/ProposalStatus__ProposalReceipt" + }, + { + "type": "null" + } + ], + "default": null + }, + "revision": { + "minimum": 1, + "title": "Revision", + "type": "integer" + }, + "schema_version": { + "const": 1, + "default": 1, + "title": "Schema Version", + "type": "integer" + }, + "validation_status": { + "anyOf": [ + { + "enum": [ + "eligible", + "needs-review", + "invalid", + "conflict" + ], + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Validation Status" + } + }, + "required": [ + "proposal_id", + "revision", + "lifecycle" + ], + "title": "ProposalStatus", + "type": "object" + }, + "ProposalStatus__Finding": { + "additionalProperties": false, + "properties": { + "code": { + "maxLength": 128, + "minLength": 1, + "pattern": "^[a-zA-Z0-9][a-zA-Z0-9._:-]*$", + "title": "Code", + "type": "string" + }, + "message": { + "maxLength": 1000, + "minLength": 1, + "title": "Message", + "type": "string" + }, + "path": { + "anyOf": [ + { + "maxLength": 256, + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Path" + }, + "schema_version": { + "const": 1, + "default": 1, + "title": "Schema Version", + "type": "integer" + } + }, + "required": [ + "code", + "message" + ], + "title": "Finding", + "type": "object" + }, + "ProposalStatus__ProposalReceipt": { + "additionalProperties": false, + "properties": { + "accepted_node_ids": { + "default": [], + "items": { + "maxLength": 128, + "minLength": 1, + "pattern": "^[a-zA-Z0-9][a-zA-Z0-9._:-]*$", + "type": "string" + }, + "title": "Accepted Node Ids", + "type": "array" + }, + "commit_id": { + "anyOf": [ + { + "pattern": "^(?:[a-f0-9]{40}|[a-f0-9]{64})$", + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Commit Id" + }, + "decided_at": { + "anyOf": [ + { + "format": "date-time", + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Decided At" + }, + "findings": { + "default": [], + "items": { + "$ref": "#/components/schemas/ProposalStatus__Finding" + }, + "title": "Findings", + "type": "array" + }, + "outcome": { + "enum": [ + "accepted", + "held", + "rejected", + "validation-failed" + ], + "title": "Outcome", + "type": "string" + }, + "payload_digest": { + "pattern": "^[a-f0-9]{64}$", + "title": "Payload Digest", + "type": "string" + }, + "proposal_id": { + "maxLength": 128, + "minLength": 1, + "pattern": "^[a-zA-Z0-9][a-zA-Z0-9._:-]*$", + "title": "Proposal Id", + "type": "string" + }, + "revision": { + "minimum": 1, + "title": "Revision", + "type": "integer" + }, + "schema_version": { + "const": 1, + "default": 1, + "title": "Schema Version", + "type": "integer" + }, + "state": { + "$ref": "#/components/schemas/ProposalStatus__ProposalState" + } + }, + "required": [ + "proposal_id", + "revision", + "payload_digest", + "state", + "outcome" + ], + "title": "ProposalReceipt", + "type": "object" + }, + "ProposalStatus__ProposalState": { + "enum": [ + "staged", + "validating", + "needs-review", + "accepted", + "rejected", + "withdrawn" + ], + "title": "ProposalState", + "type": "string" + }, + "ReadEnvelope": { + "additionalProperties": false, + "properties": { + "api_version": { + "const": "v1", + "default": "v1", + "title": "Api Version", + "type": "string" + }, + "budget_profile": { + "$ref": "#/components/schemas/BudgetProfile" + }, + "canonical_revision": { + "anyOf": [ + { + "pattern": "^(?:[a-f0-9]{40}|[a-f0-9]{64})$", + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Canonical Revision" + }, + "citations": { + "default": [], + "items": { + "$ref": "#/components/schemas/EvidenceReference" + }, + "title": "Citations", + "type": "array" + }, + "data": { + "items": { + "additionalProperties": true, + "type": "object" + }, + "title": "Data", + "type": "array" + }, + "index_revision": { + "anyOf": [ + { + "pattern": "^(?:[a-f0-9]{40}|[a-f0-9]{64})$", + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Index Revision" + }, + "next_cursor": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Next Cursor" + }, + "omissions": { + "default": [], + "items": { + "$ref": "#/components/schemas/Finding" + }, + "title": "Omissions", + "type": "array" + }, + "request_id": { + "maxLength": 128, + "minLength": 1, + "pattern": "^[a-zA-Z0-9][a-zA-Z0-9._:-]*$", + "title": "Request Id", + "type": "string" + }, + "resolved_scope": { + "$ref": "#/components/schemas/Scope" + }, + "retrieval_mode": { + "enum": [ + "keyword", + "hybrid", + "canonical" + ], + "title": "Retrieval Mode", + "type": "string" + }, + "schema_version": { + "const": 1, + "default": 1, + "title": "Schema Version", + "type": "integer" + }, + "tokens_used": { + "minimum": 0.0, + "title": "Tokens Used", + "type": "integer" + }, + "warnings": { + "default": [], + "items": { + "$ref": "#/components/schemas/Finding" + }, + "title": "Warnings", + "type": "array" + } + }, + "required": [ + "request_id", + "resolved_scope", + "data", + "canonical_revision", + "index_revision", + "retrieval_mode", + "budget_profile", + "tokens_used" + ], + "title": "ReadEnvelope", + "type": "object" + }, + "ReadEnvelope__BudgetProfile": { + "additionalProperties": false, + "properties": { + "encoding": { + "maxLength": 100, + "minLength": 1, + "title": "Encoding", + "type": "string" + }, + "schema_version": { + "const": 1, + "default": 1, + "title": "Schema Version", + "type": "integer" + }, + "tokenizer": { + "maxLength": 100, + "minLength": 1, + "title": "Tokenizer", + "type": "string" + } + }, + "required": [ + "tokenizer", + "encoding" + ], + "title": "BudgetProfile", + "type": "object" + }, + "ReadEnvelope__EvidenceReference": { + "additionalProperties": false, + "properties": { + "assertion_digest": { + "pattern": "^[a-f0-9]{64}$", + "title": "Assertion Digest", + "type": "string" + }, + "assertion_path": { + "maxLength": 256, + "minLength": 1, + "title": "Assertion Path", + "type": "string" + }, + "extraction_id": { + "pattern": "^[a-f0-9]{64}$", + "title": "Extraction Id", + "type": "string" + }, + "locator": { + "$ref": "#/components/schemas/ReadEnvelope__Locator" + }, + "normalization_profile": { + "const": "unicode-nfc-lf-v1", + "title": "Normalization Profile", + "type": "string" + }, + "quote": { + "maxLength": 8000, + "minLength": 1, + "title": "Quote", + "type": "string" + }, + "schema_version": { + "const": 1, + "default": 1, + "title": "Schema Version", + "type": "integer" + }, + "source_revision_id": { + "maxLength": 128, + "minLength": 1, + "pattern": "^[a-zA-Z0-9][a-zA-Z0-9._:-]*$", + "title": "Source Revision Id", + "type": "string" + } + }, + "required": [ + "source_revision_id", + "extraction_id", + "assertion_path", + "assertion_digest", + "quote", + "locator", + "normalization_profile" + ], + "title": "EvidenceReference", + "type": "object" + }, + "ReadEnvelope__Finding": { + "additionalProperties": false, + "properties": { + "code": { + "maxLength": 128, + "minLength": 1, + "pattern": "^[a-zA-Z0-9][a-zA-Z0-9._:-]*$", + "title": "Code", + "type": "string" + }, + "message": { + "maxLength": 1000, + "minLength": 1, + "title": "Message", + "type": "string" + }, + "path": { + "anyOf": [ + { + "maxLength": 256, + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Path" + }, + "schema_version": { + "const": 1, + "default": 1, + "title": "Schema Version", + "type": "integer" + } + }, + "required": [ + "code", + "message" + ], + "title": "Finding", + "type": "object" + }, + "ReadEnvelope__Locator": { + "additionalProperties": false, + "properties": { + "block_id": { + "maxLength": 128, + "minLength": 1, + "pattern": "^[a-zA-Z0-9][a-zA-Z0-9._:-]*$", + "title": "Block Id", + "type": "string" + }, + "end": { + "exclusiveMinimum": 0, + "title": "End", + "type": "integer" + }, + "kind": { + "enum": [ + "text_span", + "pdf_page_span", + "transcript_timestamp_span" + ], + "title": "Kind", + "type": "string" + }, + "page": { + "anyOf": [ + { + "minimum": 1, + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Page" + }, + "schema_version": { + "const": 1, + "default": 1, + "title": "Schema Version", + "type": "integer" + }, + "start": { + "minimum": 0, + "title": "Start", + "type": "integer" + }, + "time_end_ms": { + "anyOf": [ + { + "minimum": 0, + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Time End Ms" + }, + "time_start_ms": { + "anyOf": [ + { + "minimum": 0, + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Time Start Ms" + } + }, + "required": [ + "kind", + "block_id", + "start", + "end" + ], + "title": "Locator", + "type": "object" + }, + "ReadEnvelope__Scope": { + "additionalProperties": false, + "description": "Project context plus optional project includes; all-project access is deliberate.", + "properties": { + "all_projects": { + "default": false, + "title": "All Projects", + "type": "boolean" + }, + "include_projects": { + "anyOf": [ + { + "items": { + "maxLength": 128, + "minLength": 1, + "pattern": "^[a-zA-Z0-9][a-zA-Z0-9._:-]*$", + "type": "string" + }, + "maxItems": 50, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Include Projects" + }, + "include_shared": { + "default": true, + "title": "Include Shared", + "type": "boolean" + }, + "projects": { + "items": { + "maxLength": 128, + "minLength": 1, + "pattern": "^[a-zA-Z0-9][a-zA-Z0-9._:-]*$", + "type": "string" + }, + "maxItems": 50, + "minItems": 1, + "title": "Projects", + "type": "array" + }, + "schema_version": { + "const": 1, + "default": 1, + "title": "Schema Version", + "type": "integer" + } + }, + "required": [ + "projects" + ], + "title": "Scope", + "type": "object" + }, + "RunCheckpointRequest": { + "additionalProperties": false, + "properties": { + "budgets": { + "maxProperties": 100, + "patternProperties": { + "^[a-zA-Z0-9][a-zA-Z0-9._:-]*$": { + "minimum": 0, + "type": "integer" + } + }, + "propertyNames": { + "maxLength": 128, + "minLength": 1 + }, + "title": "Budgets", + "type": "object" + }, + "coverage": { + "$ref": "#/components/schemas/RunCheckpointRequest__RunCoverage" + }, + "expected_revision": { + "maximum": 2147483646, + "minimum": 0, + "title": "Expected Revision", + "type": "integer" + }, + "output_hashes": { + "default": [], + "items": { + "pattern": "^[a-f0-9]{64}$", + "type": "string" + }, + "maxItems": 1000, + "title": "Output Hashes", + "type": "array" + }, + "pending_operations": { + "default": [], + "items": { + "$ref": "#/components/schemas/RunCheckpointRequest__RunOperationReference" + }, + "maxItems": 1000, + "title": "Pending Operations", + "type": "array" + }, + "proposals": { + "default": [], + "items": { + "$ref": "#/components/schemas/RunCheckpointRequest__RunProposalReference" + }, + "maxItems": 1000, + "title": "Proposals", + "type": "array" + }, + "run_id": { + "maxLength": 128, + "minLength": 1, + "pattern": "^[a-zA-Z0-9][a-zA-Z0-9._:-]*$", + "title": "Run Id", + "type": "string" + }, + "schema_version": { + "const": 1, + "default": 1, + "title": "Schema Version", + "type": "integer" + }, + "sources": { + "default": [], + "items": { + "$ref": "#/components/schemas/RunCheckpointRequest__RunSourceReference" + }, + "maxItems": 500, + "title": "Sources", + "type": "array" + }, + "stage": { + "maxLength": 128, + "minLength": 1, + "pattern": "^[a-zA-Z0-9][a-zA-Z0-9._:-]*$", + "title": "Stage", + "type": "string" + }, + "state": { + "$ref": "#/components/schemas/RunCheckpointRequest__RunState", + "default": "active" + } + }, + "required": [ + "run_id", + "expected_revision", + "stage" + ], + "title": "RunCheckpointRequest", + "type": "object" + }, + "RunCheckpointRequest__Finding": { + "additionalProperties": false, + "properties": { + "code": { + "maxLength": 128, + "minLength": 1, + "pattern": "^[a-zA-Z0-9][a-zA-Z0-9._:-]*$", + "title": "Code", + "type": "string" + }, + "message": { + "maxLength": 1000, + "minLength": 1, + "title": "Message", + "type": "string" + }, + "path": { + "anyOf": [ + { + "maxLength": 256, + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Path" + }, + "schema_version": { + "const": 1, + "default": 1, + "title": "Schema Version", + "type": "integer" + } + }, + "required": [ + "code", + "message" + ], + "title": "Finding", + "type": "object" + }, + "RunCheckpointRequest__ProposalReceipt": { + "additionalProperties": false, + "properties": { + "accepted_node_ids": { + "default": [], + "items": { + "maxLength": 128, + "minLength": 1, + "pattern": "^[a-zA-Z0-9][a-zA-Z0-9._:-]*$", + "type": "string" + }, + "title": "Accepted Node Ids", + "type": "array" + }, + "commit_id": { + "anyOf": [ + { + "pattern": "^(?:[a-f0-9]{40}|[a-f0-9]{64})$", + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Commit Id" + }, + "decided_at": { + "anyOf": [ + { + "format": "date-time", + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Decided At" + }, + "findings": { + "default": [], + "items": { + "$ref": "#/components/schemas/RunCheckpointRequest__Finding" + }, + "title": "Findings", + "type": "array" + }, + "outcome": { + "enum": [ + "accepted", + "held", + "rejected", + "validation-failed" + ], + "title": "Outcome", + "type": "string" + }, + "payload_digest": { + "pattern": "^[a-f0-9]{64}$", + "title": "Payload Digest", + "type": "string" + }, + "proposal_id": { + "maxLength": 128, + "minLength": 1, + "pattern": "^[a-zA-Z0-9][a-zA-Z0-9._:-]*$", + "title": "Proposal Id", + "type": "string" + }, + "revision": { + "minimum": 1, + "title": "Revision", + "type": "integer" + }, + "schema_version": { + "const": 1, + "default": 1, + "title": "Schema Version", + "type": "integer" + }, + "state": { + "$ref": "#/components/schemas/RunCheckpointRequest__ProposalState" + } + }, + "required": [ + "proposal_id", + "revision", + "payload_digest", + "state", + "outcome" + ], + "title": "ProposalReceipt", + "type": "object" + }, + "RunCheckpointRequest__ProposalState": { + "enum": [ + "staged", + "validating", + "needs-review", + "accepted", + "rejected", + "withdrawn" + ], + "title": "ProposalState", + "type": "string" + }, + "RunCheckpointRequest__RunCoverage": { + "additionalProperties": false, + "properties": { + "excluded": { + "default": [], + "items": { + "$ref": "#/components/schemas/RunCheckpointRequest__RunCoverageItem" + }, + "maxItems": 10000, + "title": "Excluded", + "type": "array" + }, + "included": { + "default": [], + "items": { + "type": "string" + }, + "maxItems": 10000, + "title": "Included", + "type": "array" + }, + "schema_version": { + "const": 1, + "default": 1, + "title": "Schema Version", + "type": "integer" + }, + "selected": { + "default": [], + "items": { + "type": "string" + }, + "maxItems": 10000, + "title": "Selected", + "type": "array" + }, + "skipped": { + "default": [], + "items": { + "$ref": "#/components/schemas/RunCheckpointRequest__RunCoverageItem" + }, + "maxItems": 10000, + "title": "Skipped", + "type": "array" + } + }, + "title": "RunCoverage", + "type": "object" + }, + "RunCheckpointRequest__RunCoverageItem": { + "additionalProperties": false, + "properties": { + "item": { + "maxLength": 500, + "minLength": 1, + "title": "Item", + "type": "string" + }, + "reason": { + "maxLength": 1000, + "minLength": 1, + "title": "Reason", + "type": "string" + }, + "schema_version": { + "const": 1, + "default": 1, + "title": "Schema Version", + "type": "integer" + } + }, + "required": [ + "item", + "reason" + ], + "title": "RunCoverageItem", + "type": "object" + }, + "RunCheckpointRequest__RunOperationReference": { + "additionalProperties": false, + "properties": { + "input_sha256": { + "pattern": "^[a-f0-9]{64}$", + "title": "Input Sha256", + "type": "string" + }, + "operation_id": { + "maxLength": 128, + "minLength": 1, + "pattern": "^[a-zA-Z0-9][a-zA-Z0-9._:-]*$", + "title": "Operation Id", + "type": "string" + }, + "schema_version": { + "const": 1, + "default": 1, + "title": "Schema Version", + "type": "integer" + }, + "status": { + "enum": [ + "pending", + "uncertain", + "complete", + "failed" + ], + "title": "Status", + "type": "string" + }, + "tool_name": { + "maxLength": 128, + "minLength": 1, + "pattern": "^[a-zA-Z0-9][a-zA-Z0-9._:-]*$", + "title": "Tool Name", + "type": "string" + } + }, + "required": [ + "operation_id", + "tool_name", + "input_sha256", + "status" + ], + "title": "RunOperationReference", + "type": "object" + }, + "RunCheckpointRequest__RunProposalReference": { + "additionalProperties": false, + "properties": { + "proposal_id": { + "maxLength": 128, + "minLength": 1, + "pattern": "^[a-zA-Z0-9][a-zA-Z0-9._:-]*$", + "title": "Proposal Id", + "type": "string" + }, + "receipt": { + "anyOf": [ + { + "$ref": "#/components/schemas/RunCheckpointRequest__ProposalReceipt" + }, + { + "type": "null" + } + ], + "default": null + }, + "revision": { + "minimum": 1, + "title": "Revision", + "type": "integer" + }, + "schema_version": { + "const": 1, + "default": 1, + "title": "Schema Version", + "type": "integer" + }, + "status": { + "enum": [ + "staged", + "evaluated", + "eligible", + "needs-review", + "invalid", + "conflict", + "publishing", + "accepted", + "rejected", + "withdrawn", + "uncertain" + ], + "title": "Status", + "type": "string" + } + }, + "required": [ + "proposal_id", + "revision", + "status" + ], + "title": "RunProposalReference", + "type": "object" + }, + "RunCheckpointRequest__RunSourceReference": { + "additionalProperties": false, + "properties": { + "artifact_sha256": { + "anyOf": [ + { + "pattern": "^[a-f0-9]{64}$", + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Artifact Sha256" + }, + "extraction_id": { + "anyOf": [ + { + "pattern": "^[a-f0-9]{64}$", + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Extraction Id" + }, + "schema_version": { + "const": 1, + "default": 1, + "title": "Schema Version", + "type": "integer" + }, + "source_revision_id": { + "maxLength": 128, + "minLength": 1, + "pattern": "^[a-zA-Z0-9][a-zA-Z0-9._:-]*$", + "title": "Source Revision Id", + "type": "string" + }, + "status": { + "enum": [ + "selected", + "captured", + "extracted", + "unavailable", + "failed" + ], + "title": "Status", + "type": "string" + } + }, + "required": [ + "source_revision_id", + "status" + ], + "title": "RunSourceReference", + "type": "object" + }, + "RunCheckpointRequest__RunState": { + "enum": [ + "active", + "completed", + "blocked" + ], + "title": "RunState", + "type": "string" + }, + "RunCreateRequest": { + "additionalProperties": false, + "properties": { + "lens": { + "maxLength": 2000, + "minLength": 1, + "title": "Lens", + "type": "string" + }, + "module_pins": { + "items": { + "$ref": "#/components/schemas/RunCreateRequest__RunModulePin" + }, + "maxItems": 100, + "minItems": 1, + "title": "Module Pins", + "type": "array" + }, + "objective": { + "maxLength": 4000, + "minLength": 1, + "title": "Objective", + "type": "string" + }, + "pack_id": { + "maxLength": 128, + "minLength": 1, + "pattern": "^[a-zA-Z0-9][a-zA-Z0-9._:-]*$", + "title": "Pack Id", + "type": "string" + }, + "pack_snapshot_digest": { + "pattern": "^[a-f0-9]{64}$", + "title": "Pack Snapshot Digest", + "type": "string" + }, + "pack_version": { + "maxLength": 64, + "minLength": 1, + "title": "Pack Version", + "type": "string" + }, + "run_id": { + "maxLength": 128, + "minLength": 1, + "pattern": "^[a-zA-Z0-9][a-zA-Z0-9._:-]*$", + "title": "Run Id", + "type": "string" + }, + "schema_version": { + "const": 1, + "default": 1, + "title": "Schema Version", + "type": "integer" + }, + "scope": { + "$ref": "#/components/schemas/RunCreateRequest__Scope" + } + }, + "required": [ + "run_id", + "objective", + "scope", + "lens", + "pack_id", + "pack_version", + "pack_snapshot_digest", + "module_pins" + ], + "title": "RunCreateRequest", + "type": "object" + }, + "RunCreateRequest__RunModulePin": { + "additionalProperties": false, + "properties": { + "implementation_version": { + "maxLength": 64, + "minLength": 1, + "title": "Implementation Version", + "type": "string" + }, + "module_id": { + "maxLength": 128, + "minLength": 1, + "pattern": "^[a-zA-Z0-9][a-zA-Z0-9._:-]*$", + "title": "Module Id", + "type": "string" + }, + "schema_version": { + "const": 1, + "default": 1, + "title": "Schema Version", + "type": "integer" + }, + "sha256": { + "pattern": "^[a-f0-9]{64}$", + "title": "Sha256", + "type": "string" + } + }, + "required": [ + "module_id", + "implementation_version", + "sha256" + ], + "title": "RunModulePin", + "type": "object" + }, + "RunCreateRequest__Scope": { + "additionalProperties": false, + "description": "Project context plus optional project includes; all-project access is deliberate.", + "properties": { + "all_projects": { + "default": false, + "title": "All Projects", + "type": "boolean" + }, + "include_projects": { + "anyOf": [ + { + "items": { + "maxLength": 128, + "minLength": 1, + "pattern": "^[a-zA-Z0-9][a-zA-Z0-9._:-]*$", + "type": "string" + }, + "maxItems": 50, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Include Projects" + }, + "include_shared": { + "default": true, + "title": "Include Shared", + "type": "boolean" + }, + "projects": { + "items": { + "maxLength": 128, + "minLength": 1, + "pattern": "^[a-zA-Z0-9][a-zA-Z0-9._:-]*$", + "type": "string" + }, + "maxItems": 50, + "minItems": 1, + "title": "Projects", + "type": "array" + }, + "schema_version": { + "const": 1, + "default": 1, + "title": "Schema Version", + "type": "integer" + } + }, + "required": [ + "projects" + ], + "title": "Scope", + "type": "object" + }, + "RunRecord": { + "additionalProperties": false, + "properties": { + "budgets": { + "maxProperties": 100, + "patternProperties": { + "^[a-zA-Z0-9][a-zA-Z0-9._:-]*$": { + "minimum": 0, + "type": "integer" + } + }, + "propertyNames": { + "maxLength": 128, + "minLength": 1 + }, + "title": "Budgets", + "type": "object" + }, + "checkpoint_digest": { + "anyOf": [ + { + "pattern": "^[a-f0-9]{64}$", + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Checkpoint Digest" + }, + "coverage": { + "$ref": "#/components/schemas/RunRecord__RunCoverage" + }, + "created_at": { + "format": "date-time", + "title": "Created At", + "type": "string" + }, + "lens": { + "maxLength": 2000, + "minLength": 1, + "title": "Lens", + "type": "string" + }, + "module_pins": { + "items": { + "$ref": "#/components/schemas/RunRecord__RunModulePin" + }, + "maxItems": 100, + "minItems": 1, + "title": "Module Pins", + "type": "array" + }, + "objective": { + "maxLength": 4000, + "minLength": 1, + "title": "Objective", + "type": "string" + }, + "output_hashes": { + "default": [], + "items": { + "pattern": "^[a-f0-9]{64}$", + "type": "string" + }, + "maxItems": 1000, + "title": "Output Hashes", + "type": "array" + }, + "pack_id": { + "maxLength": 128, + "minLength": 1, + "pattern": "^[a-zA-Z0-9][a-zA-Z0-9._:-]*$", + "title": "Pack Id", + "type": "string" + }, + "pack_snapshot_digest": { + "pattern": "^[a-f0-9]{64}$", + "title": "Pack Snapshot Digest", + "type": "string" + }, + "pack_version": { + "maxLength": 64, + "minLength": 1, + "title": "Pack Version", + "type": "string" + }, + "pending_operations": { + "default": [], + "items": { + "$ref": "#/components/schemas/RunRecord__RunOperationReference" + }, + "maxItems": 1000, + "title": "Pending Operations", + "type": "array" + }, + "proposals": { + "default": [], + "items": { + "$ref": "#/components/schemas/RunRecord__RunProposalReference" + }, + "maxItems": 1000, + "title": "Proposals", + "type": "array" + }, + "revision": { + "minimum": 0, + "title": "Revision", + "type": "integer" + }, + "run_id": { + "maxLength": 128, + "minLength": 1, + "pattern": "^[a-zA-Z0-9][a-zA-Z0-9._:-]*$", + "title": "Run Id", + "type": "string" + }, + "schema_version": { + "const": 1, + "default": 1, + "title": "Schema Version", + "type": "integer" + }, + "scope": { + "$ref": "#/components/schemas/RunRecord__Scope" + }, + "sources": { + "default": [], + "items": { + "$ref": "#/components/schemas/RunRecord__RunSourceReference" + }, + "maxItems": 500, + "title": "Sources", + "type": "array" + }, + "stage": { + "maxLength": 128, + "minLength": 1, + "pattern": "^[a-zA-Z0-9][a-zA-Z0-9._:-]*$", + "title": "Stage", + "type": "string" + }, + "state": { + "$ref": "#/components/schemas/RunRecord__RunState" + }, + "updated_at": { + "format": "date-time", + "title": "Updated At", + "type": "string" + } + }, + "required": [ + "run_id", + "revision", + "state", + "objective", + "scope", + "lens", + "pack_id", + "pack_version", + "pack_snapshot_digest", + "module_pins", + "stage", + "created_at", + "updated_at" + ], + "title": "RunRecord", + "type": "object" + }, + "RunRecord__Finding": { + "additionalProperties": false, + "properties": { + "code": { + "maxLength": 128, + "minLength": 1, + "pattern": "^[a-zA-Z0-9][a-zA-Z0-9._:-]*$", + "title": "Code", + "type": "string" + }, + "message": { + "maxLength": 1000, + "minLength": 1, + "title": "Message", + "type": "string" + }, + "path": { + "anyOf": [ + { + "maxLength": 256, + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Path" + }, + "schema_version": { + "const": 1, + "default": 1, + "title": "Schema Version", + "type": "integer" + } + }, + "required": [ + "code", + "message" + ], + "title": "Finding", + "type": "object" + }, + "RunRecord__ProposalReceipt": { + "additionalProperties": false, + "properties": { + "accepted_node_ids": { + "default": [], + "items": { + "maxLength": 128, + "minLength": 1, + "pattern": "^[a-zA-Z0-9][a-zA-Z0-9._:-]*$", + "type": "string" + }, + "title": "Accepted Node Ids", + "type": "array" + }, + "commit_id": { + "anyOf": [ + { + "pattern": "^(?:[a-f0-9]{40}|[a-f0-9]{64})$", + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Commit Id" + }, + "decided_at": { + "anyOf": [ + { + "format": "date-time", + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Decided At" + }, + "findings": { + "default": [], + "items": { + "$ref": "#/components/schemas/RunRecord__Finding" + }, + "title": "Findings", + "type": "array" + }, + "outcome": { + "enum": [ + "accepted", + "held", + "rejected", + "validation-failed" + ], + "title": "Outcome", + "type": "string" + }, + "payload_digest": { + "pattern": "^[a-f0-9]{64}$", + "title": "Payload Digest", + "type": "string" + }, + "proposal_id": { + "maxLength": 128, + "minLength": 1, + "pattern": "^[a-zA-Z0-9][a-zA-Z0-9._:-]*$", + "title": "Proposal Id", + "type": "string" + }, + "revision": { + "minimum": 1, + "title": "Revision", + "type": "integer" + }, + "schema_version": { + "const": 1, + "default": 1, + "title": "Schema Version", + "type": "integer" + }, + "state": { + "$ref": "#/components/schemas/RunRecord__ProposalState" + } + }, + "required": [ + "proposal_id", + "revision", + "payload_digest", + "state", + "outcome" + ], + "title": "ProposalReceipt", + "type": "object" + }, + "RunRecord__ProposalState": { + "enum": [ + "staged", + "validating", + "needs-review", + "accepted", + "rejected", + "withdrawn" + ], + "title": "ProposalState", + "type": "string" + }, + "RunRecord__RunCoverage": { + "additionalProperties": false, + "properties": { + "excluded": { + "default": [], + "items": { + "$ref": "#/components/schemas/RunRecord__RunCoverageItem" + }, + "maxItems": 10000, + "title": "Excluded", + "type": "array" + }, + "included": { + "default": [], + "items": { + "type": "string" + }, + "maxItems": 10000, + "title": "Included", + "type": "array" + }, + "schema_version": { + "const": 1, + "default": 1, + "title": "Schema Version", + "type": "integer" + }, + "selected": { + "default": [], + "items": { + "type": "string" + }, + "maxItems": 10000, + "title": "Selected", + "type": "array" + }, + "skipped": { + "default": [], + "items": { + "$ref": "#/components/schemas/RunRecord__RunCoverageItem" + }, + "maxItems": 10000, + "title": "Skipped", + "type": "array" + } + }, + "title": "RunCoverage", + "type": "object" + }, + "RunRecord__RunCoverageItem": { + "additionalProperties": false, + "properties": { + "item": { + "maxLength": 500, + "minLength": 1, + "title": "Item", + "type": "string" + }, + "reason": { + "maxLength": 1000, + "minLength": 1, + "title": "Reason", + "type": "string" + }, + "schema_version": { + "const": 1, + "default": 1, + "title": "Schema Version", + "type": "integer" + } + }, + "required": [ + "item", + "reason" + ], + "title": "RunCoverageItem", + "type": "object" + }, + "RunRecord__RunModulePin": { + "additionalProperties": false, + "properties": { + "implementation_version": { + "maxLength": 64, + "minLength": 1, + "title": "Implementation Version", + "type": "string" + }, + "module_id": { + "maxLength": 128, + "minLength": 1, + "pattern": "^[a-zA-Z0-9][a-zA-Z0-9._:-]*$", + "title": "Module Id", + "type": "string" + }, + "schema_version": { + "const": 1, + "default": 1, + "title": "Schema Version", + "type": "integer" + }, + "sha256": { + "pattern": "^[a-f0-9]{64}$", + "title": "Sha256", + "type": "string" + } + }, + "required": [ + "module_id", + "implementation_version", + "sha256" + ], + "title": "RunModulePin", + "type": "object" + }, + "RunRecord__RunOperationReference": { + "additionalProperties": false, + "properties": { + "input_sha256": { + "pattern": "^[a-f0-9]{64}$", + "title": "Input Sha256", + "type": "string" + }, + "operation_id": { + "maxLength": 128, + "minLength": 1, + "pattern": "^[a-zA-Z0-9][a-zA-Z0-9._:-]*$", + "title": "Operation Id", + "type": "string" + }, + "schema_version": { + "const": 1, + "default": 1, + "title": "Schema Version", + "type": "integer" + }, + "status": { + "enum": [ + "pending", + "uncertain", + "complete", + "failed" + ], + "title": "Status", + "type": "string" + }, + "tool_name": { + "maxLength": 128, + "minLength": 1, + "pattern": "^[a-zA-Z0-9][a-zA-Z0-9._:-]*$", + "title": "Tool Name", + "type": "string" + } + }, + "required": [ + "operation_id", + "tool_name", + "input_sha256", + "status" + ], + "title": "RunOperationReference", + "type": "object" + }, + "RunRecord__RunProposalReference": { + "additionalProperties": false, + "properties": { + "proposal_id": { + "maxLength": 128, + "minLength": 1, + "pattern": "^[a-zA-Z0-9][a-zA-Z0-9._:-]*$", + "title": "Proposal Id", + "type": "string" + }, + "receipt": { + "anyOf": [ + { + "$ref": "#/components/schemas/RunRecord__ProposalReceipt" + }, + { + "type": "null" + } + ], + "default": null + }, + "revision": { + "minimum": 1, + "title": "Revision", + "type": "integer" + }, + "schema_version": { + "const": 1, + "default": 1, + "title": "Schema Version", + "type": "integer" + }, + "status": { + "enum": [ + "staged", + "evaluated", + "eligible", + "needs-review", + "invalid", + "conflict", + "publishing", + "accepted", + "rejected", + "withdrawn", + "uncertain" + ], + "title": "Status", + "type": "string" + } + }, + "required": [ + "proposal_id", + "revision", + "status" + ], + "title": "RunProposalReference", + "type": "object" + }, + "RunRecord__RunSourceReference": { + "additionalProperties": false, + "properties": { + "artifact_sha256": { + "anyOf": [ + { + "pattern": "^[a-f0-9]{64}$", + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Artifact Sha256" + }, + "extraction_id": { + "anyOf": [ + { + "pattern": "^[a-f0-9]{64}$", + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Extraction Id" + }, + "schema_version": { + "const": 1, + "default": 1, + "title": "Schema Version", + "type": "integer" + }, + "source_revision_id": { + "maxLength": 128, + "minLength": 1, + "pattern": "^[a-zA-Z0-9][a-zA-Z0-9._:-]*$", + "title": "Source Revision Id", + "type": "string" + }, + "status": { + "enum": [ + "selected", + "captured", + "extracted", + "unavailable", + "failed" + ], + "title": "Status", + "type": "string" + } + }, + "required": [ + "source_revision_id", + "status" + ], + "title": "RunSourceReference", + "type": "object" + }, + "RunRecord__RunState": { + "enum": [ + "active", + "completed", + "blocked" + ], + "title": "RunState", + "type": "string" + }, + "RunRecord__Scope": { + "additionalProperties": false, + "description": "Project context plus optional project includes; all-project access is deliberate.", + "properties": { + "all_projects": { + "default": false, + "title": "All Projects", + "type": "boolean" + }, + "include_projects": { + "anyOf": [ + { + "items": { + "maxLength": 128, + "minLength": 1, + "pattern": "^[a-zA-Z0-9][a-zA-Z0-9._:-]*$", + "type": "string" + }, + "maxItems": 50, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Include Projects" + }, + "include_shared": { + "default": true, + "title": "Include Shared", + "type": "boolean" + }, + "projects": { + "items": { + "maxLength": 128, + "minLength": 1, + "pattern": "^[a-zA-Z0-9][a-zA-Z0-9._:-]*$", + "type": "string" + }, + "maxItems": 50, + "minItems": 1, + "title": "Projects", + "type": "array" + }, + "schema_version": { + "const": 1, + "default": 1, + "title": "Schema Version", + "type": "integer" + } + }, + "required": [ + "projects" + ], + "title": "Scope", + "type": "object" + }, + "Scope": { + "additionalProperties": false, + "description": "Project context plus optional project includes; all-project access is deliberate.", + "properties": { + "all_projects": { + "default": false, + "title": "All Projects", + "type": "boolean" + }, + "include_projects": { + "anyOf": [ + { + "items": { + "maxLength": 128, + "minLength": 1, + "pattern": "^[a-zA-Z0-9][a-zA-Z0-9._:-]*$", + "type": "string" + }, + "maxItems": 50, + "type": "array" + }, + { + "type": "null" + } + ], + "title": "Include Projects" + }, + "include_shared": { + "default": true, + "title": "Include Shared", + "type": "boolean" + }, + "projects": { + "items": { + "maxLength": 128, + "minLength": 1, + "pattern": "^[a-zA-Z0-9][a-zA-Z0-9._:-]*$", + "type": "string" + }, + "maxItems": 50, + "minItems": 1, + "title": "Projects", + "type": "array" + }, + "schema_version": { + "const": 1, + "default": 1, + "title": "Schema Version", + "type": "integer" + } + }, + "required": [ + "projects" + ], + "title": "Scope", + "type": "object" + }, + "SearchRequest": { + "additionalProperties": false, + "properties": { + "as_of_from": { + "anyOf": [ + { + "format": "date", + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "As Of From" + }, + "as_of_to": { + "anyOf": [ + { + "format": "date", + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "As Of To" + }, + "audience": { + "default": [], + "items": { + "maxLength": 100, + "minLength": 1, + "type": "string" + }, + "maxItems": 20, + "title": "Audience", + "type": "array" + }, + "budget_tokens": { + "maximum": 50000, + "minimum": 256, + "title": "Budget Tokens", + "type": "integer" + }, + "confidences": { + "default": [], + "items": { + "$ref": "#/components/schemas/SearchRequest__Confidence" + }, + "maxItems": 3, + "title": "Confidences", + "type": "array" + }, + "cursor": { + "anyOf": [ + { + "maxLength": 512, + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Cursor" + }, + "domain": { + "default": [], + "items": { + "maxLength": 100, + "minLength": 1, + "type": "string" + }, + "maxItems": 20, + "title": "Domain", + "type": "array" + }, + "limit": { + "default": 20, + "maximum": 50, + "minimum": 1, + "title": "Limit", + "type": "integer" + }, + "node_types": { + "default": [], + "items": { + "$ref": "#/components/schemas/SearchRequest__NodeType" + }, + "maxItems": 9, + "title": "Node Types", + "type": "array" + }, + "query": { + "default": "", + "maxLength": 1000, + "title": "Query", + "type": "string" + }, + "schema_version": { + "const": 1, + "default": 1, + "title": "Schema Version", + "type": "integer" + }, + "scope": { + "$ref": "#/components/schemas/SearchRequest__Scope" + }, + "stale_only": { + "default": false, + "title": "Stale Only", + "type": "boolean" + }, + "tags": { + "default": [], + "items": { + "maxLength": 100, + "minLength": 1, + "type": "string" + }, + "maxItems": 20, + "title": "Tags", + "type": "array" + }, + "use_for": { + "default": [], + "items": { + "maxLength": 100, + "minLength": 1, + "type": "string" + }, + "maxItems": 20, + "title": "Use For", + "type": "array" + } + }, + "required": [ + "scope", + "budget_tokens" + ], + "title": "SearchRequest", + "type": "object" + }, + "SearchRequestBody": { + "additionalProperties": false, + "properties": { + "as_of_from": { + "anyOf": [ + { + "format": "date", + "type": "string" + }, + { + "type": "null" + } + ], + "title": "As Of From" + }, + "as_of_to": { + "anyOf": [ + { + "format": "date", + "type": "string" + }, + { + "type": "null" + } + ], + "title": "As Of To" + }, + "audience": { + "items": { + "maxLength": 100, + "minLength": 1, + "type": "string" + }, + "maxItems": 20, + "title": "Audience", + "type": "array" + }, + "budget_tokens": { + "maximum": 50000.0, + "minimum": 256.0, + "title": "Budget Tokens", + "type": "integer" + }, + "confidences": { + "items": { + "$ref": "#/components/schemas/Confidence" + }, + "maxItems": 3, + "title": "Confidences", + "type": "array" + }, + "cursor": { + "anyOf": [ + { + "maxLength": 512, + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Cursor" + }, + "domain": { + "items": { + "maxLength": 100, + "minLength": 1, + "type": "string" + }, + "maxItems": 20, + "title": "Domain", + "type": "array" + }, + "limit": { + "default": 20, + "maximum": 50.0, + "minimum": 1.0, + "title": "Limit", + "type": "integer" + }, + "node_types": { + "items": { + "type": "string" + }, + "maxItems": 9, + "title": "Node Types", + "type": "array" + }, + "query": { + "default": "", + "maxLength": 1000, + "title": "Query", + "type": "string" + }, + "scope": { + "$ref": "#/components/schemas/SearchScopeBody" + }, + "stale_only": { + "default": false, + "title": "Stale Only", + "type": "boolean" + }, + "tags": { + "items": { + "maxLength": 100, + "minLength": 1, + "type": "string" + }, + "maxItems": 20, + "title": "Tags", + "type": "array" + }, + "use_for": { + "items": { + "maxLength": 100, + "minLength": 1, + "type": "string" + }, + "maxItems": 20, + "title": "Use For", + "type": "array" + } + }, + "required": [ + "scope", + "budget_tokens" + ], + "title": "SearchRequestBody", + "type": "object" + }, + "SearchRequest__Confidence": { + "enum": [ + "low", + "medium", + "high" + ], + "title": "Confidence", + "type": "string" + }, + "SearchRequest__NodeType": { + "enum": [ + "finding", + "problem", + "technique", + "case", + "entity", + "event", + "concept", + "datapoint", + "synthesis" + ], + "title": "NodeType", + "type": "string" + }, + "SearchRequest__Scope": { + "additionalProperties": false, + "description": "Project context plus optional project includes; all-project access is deliberate.", + "properties": { + "all_projects": { + "default": false, + "title": "All Projects", + "type": "boolean" + }, + "include_projects": { + "anyOf": [ + { + "items": { + "maxLength": 128, + "minLength": 1, + "pattern": "^[a-zA-Z0-9][a-zA-Z0-9._:-]*$", + "type": "string" + }, + "maxItems": 50, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Include Projects" + }, + "include_shared": { + "default": true, + "title": "Include Shared", + "type": "boolean" + }, + "projects": { + "items": { + "maxLength": 128, + "minLength": 1, + "pattern": "^[a-zA-Z0-9][a-zA-Z0-9._:-]*$", + "type": "string" + }, + "maxItems": 50, + "minItems": 1, + "title": "Projects", + "type": "array" + }, + "schema_version": { + "const": 1, + "default": 1, + "title": "Schema Version", + "type": "integer" + } + }, + "required": [ + "projects" + ], + "title": "Scope", + "type": "object" + }, + "SearchScopeBody": { + "additionalProperties": false, + "properties": { + "all_projects": { + "default": false, + "title": "All Projects", + "type": "boolean" + }, + "include_projects": { + "anyOf": [ + { + "items": { + "maxLength": 128, + "minLength": 1, + "pattern": "^[a-zA-Z0-9][a-zA-Z0-9._:-]*$", + "type": "string" + }, + "maxItems": 50, + "type": "array" + }, + { + "type": "null" + } + ], + "title": "Include Projects" + }, + "include_shared": { + "default": true, + "title": "Include Shared", + "type": "boolean" + }, + "projects": { + "items": { + "maxLength": 128, + "minLength": 1, + "pattern": "^[a-zA-Z0-9][a-zA-Z0-9._:-]*$", + "type": "string" + }, + "maxItems": 50, + "minItems": 1, + "title": "Projects", + "type": "array" + } + }, + "required": [ + "projects" + ], + "title": "SearchScopeBody", + "type": "object" + }, + "ServiceDiagnostics": { + "additionalProperties": false, + "properties": { + "api_version": { + "const": "v1", + "default": "v1", + "title": "Api Version", + "type": "string" + }, + "authentication": { + "enum": [ + "bearer", + "browser_session" + ], + "title": "Authentication", + "type": "string" + }, + "authorized_operations": { + "default": [], + "items": { + "maxLength": 128, + "minLength": 1, + "pattern": "^[a-zA-Z0-9][a-zA-Z0-9._:-]*$", + "type": "string" + }, + "title": "Authorized Operations", + "type": "array" + }, + "authorized_projects": { + "default": [], + "items": { + "maxLength": 128, + "minLength": 1, + "pattern": "^[a-zA-Z0-9][a-zA-Z0-9._:-]*$", + "type": "string" + }, + "title": "Authorized Projects", + "type": "array" + }, + "canonical_revision": { + "pattern": "^(?:[a-f0-9]{40}|[a-f0-9]{64})$", + "title": "Canonical Revision", + "type": "string" + }, + "capabilities": { + "default": [], + "items": { + "maxLength": 128, + "minLength": 1, + "pattern": "^[a-zA-Z0-9][a-zA-Z0-9._:-]*$", + "type": "string" + }, + "title": "Capabilities", + "type": "array" + }, + "catalog_sha256": { + "pattern": "^[a-f0-9]{64}$", + "title": "Catalog Sha256", + "type": "string" + }, + "connection_status": { + "const": "authenticated", + "default": "authenticated", + "title": "Connection Status", + "type": "string" + }, + "index_revision": { + "anyOf": [ + { + "pattern": "^(?:[a-f0-9]{40}|[a-f0-9]{64})$", + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Index Revision" + }, + "index_schema_version": { + "anyOf": [ + { + "minimum": 1.0, + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Index Schema Version" + }, + "index_state": { + "enum": [ + "current", + "lagging", + "not_built", + "not_configured", + "unavailable" + ], + "title": "Index State", + "type": "string" + }, + "max_upload_bytes": { + "minimum": 1.0, + "title": "Max Upload Bytes", + "type": "integer" + }, + "roles": { + "default": [], + "items": { + "maxLength": 128, + "minLength": 1, + "pattern": "^[a-zA-Z0-9][a-zA-Z0-9._:-]*$", + "type": "string" + }, + "title": "Roles", + "type": "array" + }, + "schema_version": { + "const": 1, + "default": 1, + "title": "Schema Version", + "type": "integer" + }, + "selected_scope": { + "anyOf": [ + { + "$ref": "#/components/schemas/Scope" + }, + { + "type": "null" + } + ] + }, + "service_version": { + "maxLength": 64, + "minLength": 1, + "title": "Service Version", + "type": "string" + }, + "supported_media_types": { + "default": [], + "items": { + "type": "string" + }, + "maxItems": 20, + "title": "Supported Media Types", + "type": "array" + }, + "transport": { + "enum": [ + "http", + "mcp" + ], + "title": "Transport", + "type": "string" + }, + "upload_chunk_bytes": { + "minimum": 1.0, + "title": "Upload Chunk Bytes", + "type": "integer" + }, + "workflow_status": { + "const": "not_verified", + "default": "not_verified", + "title": "Workflow Status", + "type": "string" + } + }, + "required": [ + "service_version", + "catalog_sha256", + "transport", + "authentication", + "max_upload_bytes", + "upload_chunk_bytes", + "canonical_revision", + "index_state" + ], + "title": "ServiceDiagnostics", + "type": "object" + }, + "ServiceDiagnostics__Scope": { + "additionalProperties": false, + "description": "Project context plus optional project includes; all-project access is deliberate.", + "properties": { + "all_projects": { + "default": false, + "title": "All Projects", + "type": "boolean" + }, + "include_projects": { + "anyOf": [ + { + "items": { + "maxLength": 128, + "minLength": 1, + "pattern": "^[a-zA-Z0-9][a-zA-Z0-9._:-]*$", + "type": "string" + }, + "maxItems": 50, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Include Projects" + }, + "include_shared": { + "default": true, + "title": "Include Shared", + "type": "boolean" + }, + "projects": { + "items": { + "maxLength": 128, + "minLength": 1, + "pattern": "^[a-zA-Z0-9][a-zA-Z0-9._:-]*$", + "type": "string" + }, + "maxItems": 50, + "minItems": 1, + "title": "Projects", + "type": "array" + }, + "schema_version": { + "const": 1, + "default": 1, + "title": "Schema Version", + "type": "integer" + } + }, + "required": [ + "projects" + ], + "title": "Scope", + "type": "object" + }, + "SourceListRequestBody": { + "additionalProperties": false, + "properties": { + "budget_tokens": { + "maximum": 50000.0, + "minimum": 256.0, + "title": "Budget Tokens", + "type": "integer" + }, + "cursor": { + "anyOf": [ + { + "maxLength": 512, + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Cursor" + }, + "limit": { + "default": 20, + "maximum": 50.0, + "minimum": 1.0, + "title": "Limit", + "type": "integer" + }, + "scope": { + "$ref": "#/components/schemas/SearchScopeBody" + } + }, + "required": [ + "scope", + "budget_tokens" + ], + "title": "SourceListRequestBody", + "type": "object" + }, + "SourceRevisionRecord": { + "additionalProperties": false, + "properties": { + "archive_status": { + "enum": [ + "archived", + "reference-only", + "capture-failed" + ], + "title": "Archive Status", + "type": "string" + }, + "artifact_sha256": { + "anyOf": [ + { + "pattern": "^[a-f0-9]{64}$", + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Artifact Sha256" + }, + "authors": { + "default": [], + "items": { + "type": "string" + }, + "maxItems": 50, + "title": "Authors", + "type": "array" + }, + "byte_length": { + "minimum": 0, + "title": "Byte Length", + "type": "integer" + }, + "capture_method": { + "default": "unspecified", + "enum": [ + "unspecified", + "local-file", + "url-fetch", + "pasted-text", + "notebooklm-export", + "transcript" + ], + "title": "Capture Method", + "type": "string" + }, + "captured_at": { + "format": "date-time", + "title": "Captured At", + "type": "string" + }, + "final_url": { + "anyOf": [ + { + "maxLength": 4000, + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Final Url" + }, + "ingested_for": { + "items": { + "maxLength": 128, + "minLength": 1, + "pattern": "^[a-zA-Z0-9][a-zA-Z0-9._:-]*$", + "type": "string" + }, + "maxItems": 50, + "minItems": 1, + "title": "Ingested For", + "type": "array" + }, + "license": { + "maxLength": 500, + "minLength": 1, + "title": "License", + "type": "string" + }, + "media_type": { + "maxLength": 128, + "minLength": 1, + "title": "Media Type", + "type": "string" + }, + "original_url": { + "anyOf": [ + { + "maxLength": 4000, + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Original Url" + }, + "publication_date": { + "anyOf": [ + { + "format": "date-time", + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Publication Date" + }, + "publisher": { + "anyOf": [ + { + "maxLength": 300, + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Publisher" + }, + "reliability_grade": { + "$ref": "#/components/schemas/SourceRevisionRecord__ReliabilityGrade" + }, + "revision_id": { + "maxLength": 128, + "minLength": 1, + "pattern": "^[a-zA-Z0-9][a-zA-Z0-9._:-]*$", + "title": "Revision Id", + "type": "string" + }, + "schema_version": { + "const": 1, + "default": 1, + "title": "Schema Version", + "type": "integer" + }, + "source_id": { + "maxLength": 128, + "minLength": 1, + "pattern": "^[a-zA-Z0-9][a-zA-Z0-9._:-]*$", + "title": "Source Id", + "type": "string" + }, + "source_type": { + "enum": [ + "paper", + "review", + "press", + "vendor", + "internal", + "talk", + "export", + "other" + ], + "title": "Source Type", + "type": "string" + }, + "title": { + "maxLength": 500, + "minLength": 1, + "title": "Title", + "type": "string" + } + }, + "required": [ + "source_id", + "revision_id", + "media_type", + "byte_length", + "captured_at", + "title", + "license", + "source_type", + "reliability_grade", + "ingested_for", + "archive_status" + ], + "title": "SourceRevisionRecord", + "type": "object" + }, + "SourceRevisionRecord__ReliabilityGrade": { + "enum": [ + "low", + "medium", + "high" + ], + "title": "ReliabilityGrade", + "type": "string" + }, + "TypedEdge": { + "additionalProperties": false, + "properties": { + "conditions": { + "anyOf": [ + { + "maxLength": 2000, + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Conditions" + }, + "edge_id": { + "maxLength": 128, + "minLength": 1, + "pattern": "^[a-zA-Z0-9][a-zA-Z0-9._:-]*$", + "title": "Edge Id", + "type": "string" + }, + "edge_type": { + "enum": [ + "solves", + "addresses", + "causes", + "supports", + "contradicts", + "instance_of", + "part_of", + "precedes", + "supersedes", + "compared_with", + "derived_from", + "applies_to" + ], + "title": "Edge Type", + "type": "string" + }, + "evidence": { + "default": [], + "items": { + "$ref": "#/components/schemas/TypedEdge__EvidenceReference" + }, + "title": "Evidence", + "type": "array" + }, + "interpretation_rationale": { + "anyOf": [ + { + "maxLength": 4000, + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Interpretation Rationale" + }, + "schema_version": { + "const": 1, + "default": 1, + "title": "Schema Version", + "type": "integer" + }, + "source": { + "$ref": "#/components/schemas/TypedEdge__EdgeEndpoint" + }, + "target": { + "$ref": "#/components/schemas/TypedEdge__EdgeEndpoint" + } + }, + "required": [ + "edge_id", + "edge_type", + "source", + "target" + ], + "title": "TypedEdge", + "type": "object" + }, + "TypedEdge__EdgeEndpoint": { + "additionalProperties": false, + "properties": { + "id": { + "maxLength": 128, + "minLength": 1, + "pattern": "^[a-zA-Z0-9][a-zA-Z0-9._:-]*$", + "title": "Id", + "type": "string" + }, + "kind": { + "enum": [ + "node", + "source", + "project" + ], + "title": "Kind", + "type": "string" + }, + "schema_version": { + "const": 1, + "default": 1, + "title": "Schema Version", + "type": "integer" + } + }, + "required": [ + "kind", + "id" + ], + "title": "EdgeEndpoint", + "type": "object" + }, + "TypedEdge__EvidenceReference": { + "additionalProperties": false, + "properties": { + "assertion_digest": { + "pattern": "^[a-f0-9]{64}$", + "title": "Assertion Digest", + "type": "string" + }, + "assertion_path": { + "maxLength": 256, + "minLength": 1, + "title": "Assertion Path", + "type": "string" + }, + "extraction_id": { + "pattern": "^[a-f0-9]{64}$", + "title": "Extraction Id", + "type": "string" + }, + "locator": { + "$ref": "#/components/schemas/TypedEdge__Locator" + }, + "normalization_profile": { + "const": "unicode-nfc-lf-v1", + "title": "Normalization Profile", + "type": "string" + }, + "quote": { + "maxLength": 8000, + "minLength": 1, + "title": "Quote", + "type": "string" + }, + "schema_version": { + "const": 1, + "default": 1, + "title": "Schema Version", + "type": "integer" + }, + "source_revision_id": { + "maxLength": 128, + "minLength": 1, + "pattern": "^[a-zA-Z0-9][a-zA-Z0-9._:-]*$", + "title": "Source Revision Id", + "type": "string" + } + }, + "required": [ + "source_revision_id", + "extraction_id", + "assertion_path", + "assertion_digest", + "quote", + "locator", + "normalization_profile" + ], + "title": "EvidenceReference", + "type": "object" + }, + "TypedEdge__Locator": { + "additionalProperties": false, + "properties": { + "block_id": { + "maxLength": 128, + "minLength": 1, + "pattern": "^[a-zA-Z0-9][a-zA-Z0-9._:-]*$", + "title": "Block Id", + "type": "string" + }, + "end": { + "exclusiveMinimum": 0, + "title": "End", + "type": "integer" + }, + "kind": { + "enum": [ + "text_span", + "pdf_page_span", + "transcript_timestamp_span" + ], + "title": "Kind", + "type": "string" + }, + "page": { + "anyOf": [ + { + "minimum": 1, + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Page" + }, + "schema_version": { + "const": 1, + "default": 1, + "title": "Schema Version", + "type": "integer" + }, + "start": { + "minimum": 0, + "title": "Start", + "type": "integer" + }, + "time_end_ms": { + "anyOf": [ + { + "minimum": 0, + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Time End Ms" + }, + "time_start_ms": { + "anyOf": [ + { + "minimum": 0, + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Time Start Ms" + } + }, + "required": [ + "kind", + "block_id", + "start", + "end" + ], + "title": "Locator", + "type": "object" + }, + "ValidationError": { + "properties": { + "ctx": { + "title": "Context", + "type": "object" + }, + "input": { + "title": "Input" + }, + "loc": { + "items": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "integer" + } + ] + }, + "title": "Location", + "type": "array" + }, + "msg": { + "title": "Message", + "type": "string" + }, + "type": { + "title": "Error Type", + "type": "string" + } + }, + "required": [ + "loc", + "msg", + "type" + ], + "title": "ValidationError", + "type": "object" + } + } + }, + "info": { + "title": "GEWU", + "version": "0.1.0" + }, + "openapi": "3.1.0", + "paths": { + "/auth/owner-session": { + "post": { + "operationId": "owner_session_create", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/OwnerSessionRequestBody" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "additionalProperties": { + "type": "string" + }, + "title": "Response Owner Session Create", + "type": "object" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Create Owner Session", + "tags": [ + "identity" + ] + } + }, + "/health": { + "get": { + "operationId": "health", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "additionalProperties": { + "type": "string" + }, + "title": "Response Health", + "type": "object" + } + } + }, + "description": "Successful Response" + } + }, + "summary": "Health", + "tags": [ + "health" + ] + } + }, + "/v1/admin/credentials": { + "get": { + "operationId": "admin_list_credentials", + "parameters": [ + { + "in": "query", + "name": "limit", + "required": false, + "schema": { + "default": 50, + "maximum": 100, + "minimum": 1, + "title": "Limit", + "type": "integer" + } + }, + { + "in": "query", + "name": "offset", + "required": false, + "schema": { + "default": 0, + "minimum": 0, + "title": "Offset", + "type": "integer" + } + }, + { + "in": "header", + "name": "authorization", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Authorization" + } + }, + { + "in": "header", + "name": "X-GEWU-CSRF", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "X-Gewu-Csrf" + } + }, + { + "in": "cookie", + "name": "gewu_session", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Gewu Session" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "items": { + "$ref": "#/components/schemas/CredentialSummary" + }, + "title": "Response Admin List Credentials", + "type": "array" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Credentials", + "tags": [ + "control" + ] + }, + "post": { + "operationId": "admin_create_credential", + "parameters": [ + { + "in": "header", + "name": "authorization", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Authorization" + } + }, + { + "in": "header", + "name": "X-GEWU-CSRF", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "X-Gewu-Csrf" + } + }, + { + "in": "cookie", + "name": "gewu_session", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Gewu Session" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CredentialCreateBody" + } + } + }, + "required": true + }, + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/IssuedCredential" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Create", + "tags": [ + "control" + ] + } + }, + "/v1/admin/credentials/{credential_id}": { + "delete": { + "operationId": "admin_revoke_credential", + "parameters": [ + { + "in": "path", + "name": "credential_id", + "required": true, + "schema": { + "maxLength": 128, + "minLength": 1, + "pattern": "^[a-zA-Z0-9][a-zA-Z0-9._:-]*$", + "title": "Credential Id", + "type": "string" + } + }, + { + "in": "header", + "name": "authorization", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Authorization" + } + }, + { + "in": "header", + "name": "X-GEWU-CSRF", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "X-Gewu-Csrf" + } + }, + { + "in": "cookie", + "name": "gewu_session", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Gewu Session" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "additionalProperties": { + "type": "string" + }, + "title": "Response Admin Revoke Credential", + "type": "object" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Revoke", + "tags": [ + "control" + ] + }, + "patch": { + "operationId": "admin_update_credential", + "parameters": [ + { + "in": "path", + "name": "credential_id", + "required": true, + "schema": { + "maxLength": 128, + "minLength": 1, + "pattern": "^[a-zA-Z0-9][a-zA-Z0-9._:-]*$", + "title": "Credential Id", + "type": "string" + } + }, + { + "in": "header", + "name": "authorization", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Authorization" + } + }, + { + "in": "header", + "name": "X-GEWU-CSRF", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "X-Gewu-Csrf" + } + }, + { + "in": "cookie", + "name": "gewu_session", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Gewu Session" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CredentialUpdateBody" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CredentialSummary" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Update Credential", + "tags": [ + "control" + ] + } + }, + "/v1/admin/events": { + "get": { + "operationId": "admin_list_events", + "parameters": [ + { + "in": "query", + "name": "limit", + "required": false, + "schema": { + "default": 50, + "maximum": 100, + "minimum": 1, + "title": "Limit", + "type": "integer" + } + }, + { + "in": "header", + "name": "authorization", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Authorization" + } + }, + { + "in": "header", + "name": "X-GEWU-CSRF", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "X-Gewu-Csrf" + } + }, + { + "in": "cookie", + "name": "gewu_session", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Gewu Session" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "items": { + "$ref": "#/components/schemas/AdminEvent" + }, + "title": "Response Admin List Events", + "type": "array" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Events", + "tags": [ + "control" + ] + } + }, + "/v1/admin/projects": { + "get": { + "operationId": "admin_list_projects", + "parameters": [ + { + "in": "header", + "name": "authorization", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Authorization" + } + }, + { + "in": "cookie", + "name": "gewu_session", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Gewu Session" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "items": { + "$ref": "#/components/schemas/ProjectRecord" + }, + "title": "Response Admin List Projects", + "type": "array" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Admin List Projects", + "tags": [ + "control" + ] + }, + "post": { + "operationId": "project_create", + "parameters": [ + { + "in": "header", + "name": "authorization", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Authorization" + } + }, + { + "in": "header", + "name": "X-GEWU-CSRF", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "X-Gewu-Csrf" + } + }, + { + "in": "cookie", + "name": "gewu_session", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Gewu Session" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProjectCreateBody" + } + } + }, + "required": true + }, + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProjectRecord" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Project Create", + "tags": [ + "control" + ] + } + }, + "/v1/admin/projects/{project_id}": { + "patch": { + "operationId": "project_update_content", + "parameters": [ + { + "in": "path", + "name": "project_id", + "required": true, + "schema": { + "maxLength": 128, + "minLength": 1, + "pattern": "^[a-zA-Z0-9][a-zA-Z0-9._:-]*$", + "title": "Project Id", + "type": "string" + } + }, + { + "in": "header", + "name": "authorization", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Authorization" + } + }, + { + "in": "header", + "name": "X-GEWU-CSRF", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "X-Gewu-Csrf" + } + }, + { + "in": "cookie", + "name": "gewu_session", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Gewu Session" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProjectContentUpdateBody" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProjectRecord" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Project Update Content", + "tags": [ + "control" + ] + } + }, + "/v1/admin/projects/{project_id}/archive": { + "post": { + "operationId": "project_archive", + "parameters": [ + { + "in": "path", + "name": "project_id", + "required": true, + "schema": { + "maxLength": 128, + "minLength": 1, + "pattern": "^[a-zA-Z0-9][a-zA-Z0-9._:-]*$", + "title": "Project Id", + "type": "string" + } + }, + { + "in": "header", + "name": "authorization", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Authorization" + } + }, + { + "in": "header", + "name": "X-GEWU-CSRF", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "X-Gewu-Csrf" + } + }, + { + "in": "cookie", + "name": "gewu_session", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Gewu Session" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProjectRecord" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Project Archive", + "tags": [ + "control" + ] + } + }, + "/v1/admin/users": { + "get": { + "operationId": "admin_list_accounts", + "parameters": [ + { + "in": "query", + "name": "limit", + "required": false, + "schema": { + "default": 50, + "maximum": 100, + "minimum": 1, + "title": "Limit", + "type": "integer" + } + }, + { + "in": "query", + "name": "offset", + "required": false, + "schema": { + "default": 0, + "minimum": 0, + "title": "Offset", + "type": "integer" + } + }, + { + "in": "header", + "name": "authorization", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Authorization" + } + }, + { + "in": "header", + "name": "X-GEWU-CSRF", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "X-Gewu-Csrf" + } + }, + { + "in": "cookie", + "name": "gewu_session", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Gewu Session" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "items": { + "$ref": "#/components/schemas/AccountSummary" + }, + "title": "Response Admin List Accounts", + "type": "array" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Users", + "tags": [ + "control" + ] + }, + "post": { + "operationId": "admin_create_account", + "parameters": [ + { + "in": "header", + "name": "authorization", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Authorization" + } + }, + { + "in": "header", + "name": "X-GEWU-CSRF", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "X-Gewu-Csrf" + } + }, + { + "in": "cookie", + "name": "gewu_session", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Gewu Session" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AccountCreateBody" + } + } + }, + "required": true + }, + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AccountSummary" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Create User", + "tags": [ + "control" + ] + } + }, + "/v1/admin/users/{username}": { + "delete": { + "operationId": "admin_disable_account", + "parameters": [ + { + "in": "path", + "name": "username", + "required": true, + "schema": { + "pattern": "^[a-z0-9][a-z0-9._-]{0,63}$", + "title": "Username", + "type": "string" + } + }, + { + "in": "header", + "name": "authorization", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Authorization" + } + }, + { + "in": "header", + "name": "X-GEWU-CSRF", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "X-Gewu-Csrf" + } + }, + { + "in": "cookie", + "name": "gewu_session", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Gewu Session" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "additionalProperties": { + "type": "string" + }, + "title": "Response Admin Disable Account", + "type": "object" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Disable User", + "tags": [ + "control" + ] + }, + "patch": { + "operationId": "admin_update_account", + "parameters": [ + { + "in": "path", + "name": "username", + "required": true, + "schema": { + "pattern": "^[a-z0-9][a-z0-9._-]{0,63}$", + "title": "Username", + "type": "string" + } + }, + { + "in": "header", + "name": "authorization", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Authorization" + } + }, + { + "in": "header", + "name": "X-GEWU-CSRF", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "X-Gewu-Csrf" + } + }, + { + "in": "cookie", + "name": "gewu_session", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Gewu Session" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AccountUpdateBody" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AccountSummary" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Update User", + "tags": [ + "control" + ] + } + }, + "/v1/diagnostics": { + "get": { + "operationId": "diagnostics", + "parameters": [ + { + "in": "query", + "name": "projects", + "required": false, + "schema": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "title": "Projects" + } + }, + { + "in": "query", + "name": "include_shared", + "required": false, + "schema": { + "default": false, + "title": "Include Shared", + "type": "boolean" + } + }, + { + "in": "header", + "name": "authorization", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Authorization" + } + }, + { + "in": "cookie", + "name": "gewu_session", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Gewu Session" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ServiceDiagnostics" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Diagnostics", + "tags": [ + "diagnostics" + ] + } + }, + "/v1/me": { + "get": { + "operationId": "account_current", + "parameters": [ + { + "in": "header", + "name": "authorization", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Authorization" + } + }, + { + "in": "header", + "name": "X-GEWU-CSRF", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "X-Gewu-Csrf" + } + }, + { + "in": "cookie", + "name": "gewu_session", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Gewu Session" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CurrentUser" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Me", + "tags": [ + "identity" + ] + } + }, + "/v1/me/agent-credentials": { + "get": { + "operationId": "account_list_agent_credentials", + "parameters": [ + { + "in": "query", + "name": "limit", + "required": false, + "schema": { + "default": 50, + "maximum": 100, + "minimum": 1, + "title": "Limit", + "type": "integer" + } + }, + { + "in": "query", + "name": "offset", + "required": false, + "schema": { + "default": 0, + "minimum": 0, + "title": "Offset", + "type": "integer" + } + }, + { + "in": "header", + "name": "authorization", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Authorization" + } + }, + { + "in": "header", + "name": "X-GEWU-CSRF", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "X-Gewu-Csrf" + } + }, + { + "in": "cookie", + "name": "gewu_session", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Gewu Session" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "items": { + "$ref": "#/components/schemas/CredentialSummary" + }, + "title": "Response Account List Agent Credentials", + "type": "array" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Agents", + "tags": [ + "identity" + ] + }, + "post": { + "operationId": "account_create_agent_credential", + "parameters": [ + { + "in": "header", + "name": "authorization", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Authorization" + } + }, + { + "in": "header", + "name": "X-GEWU-CSRF", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "X-Gewu-Csrf" + } + }, + { + "in": "cookie", + "name": "gewu_session", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Gewu Session" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AgentCreateBody" + } + } + }, + "required": true + }, + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/IssuedCredential" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Create Agent", + "tags": [ + "identity" + ] + } + }, + "/v1/me/agent-credentials/{credential_id}": { + "delete": { + "operationId": "account_revoke_agent_credential", + "parameters": [ + { + "in": "path", + "name": "credential_id", + "required": true, + "schema": { + "maxLength": 128, + "minLength": 1, + "pattern": "^[a-zA-Z0-9][a-zA-Z0-9._:-]*$", + "title": "Credential Id", + "type": "string" + } + }, + { + "in": "header", + "name": "authorization", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Authorization" + } + }, + { + "in": "header", + "name": "X-GEWU-CSRF", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "X-Gewu-Csrf" + } + }, + { + "in": "cookie", + "name": "gewu_session", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Gewu Session" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "additionalProperties": { + "type": "string" + }, + "title": "Response Account Revoke Agent Credential", + "type": "object" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Revoke Agent", + "tags": [ + "identity" + ] + } + }, + "/v1/me/password": { + "post": { + "operationId": "account_change_password", + "parameters": [ + { + "in": "header", + "name": "authorization", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Authorization" + } + }, + { + "in": "header", + "name": "X-GEWU-CSRF", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "X-Gewu-Csrf" + } + }, + { + "in": "cookie", + "name": "gewu_session", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Gewu Session" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PasswordChangeBody" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "additionalProperties": { + "type": "string" + }, + "title": "Response Account Change Password", + "type": "object" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Change Password", + "tags": [ + "identity" + ] + } + }, + "/v1/neighbors": { + "post": { + "operationId": "neighbors", + "parameters": [ + { + "in": "header", + "name": "authorization", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Authorization" + } + }, + { + "in": "header", + "name": "X-GEWU-CSRF", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "X-Gewu-Csrf" + } + }, + { + "in": "cookie", + "name": "gewu_session", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Gewu Session" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NeighborRequestBody" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReadEnvelope" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Neighbors", + "tags": [ + "knowledge" + ] + } + }, + "/v1/projects": { + "get": { + "operationId": "list_projects", + "parameters": [ + { + "in": "header", + "name": "authorization", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Authorization" + } + }, + { + "in": "cookie", + "name": "gewu_session", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Gewu Session" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "items": { + "type": "string" + }, + "title": "Response List Projects", + "type": "array" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "List Projects", + "tags": [ + "knowledge" + ] + } + }, + "/v1/projects/{project_id}/nodes/{node_id}": { + "get": { + "operationId": "get_node", + "parameters": [ + { + "in": "path", + "name": "project_id", + "required": true, + "schema": { + "maxLength": 128, + "minLength": 1, + "pattern": "^[a-zA-Z0-9][a-zA-Z0-9._:-]*$", + "title": "Project Id", + "type": "string" + } + }, + { + "in": "path", + "name": "node_id", + "required": true, + "schema": { + "maxLength": 128, + "minLength": 1, + "pattern": "^[a-zA-Z0-9][a-zA-Z0-9._:-]*$", + "title": "Node Id", + "type": "string" + } + }, + { + "in": "query", + "name": "revision", + "required": false, + "schema": { + "anyOf": [ + { + "pattern": "^(?:[a-f0-9]{40}|[a-f0-9]{64})$", + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Revision" + } + }, + { + "in": "header", + "name": "authorization", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Authorization" + } + }, + { + "in": "cookie", + "name": "gewu_session", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Gewu Session" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReadEnvelope" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Get Node", + "tags": [ + "knowledge" + ] + } + }, + "/v1/projects/{project_id}/nodes/{node_id}/history": { + "get": { + "operationId": "node_history", + "parameters": [ + { + "in": "path", + "name": "project_id", + "required": true, + "schema": { + "maxLength": 128, + "minLength": 1, + "pattern": "^[a-zA-Z0-9][a-zA-Z0-9._:-]*$", + "title": "Project Id", + "type": "string" + } + }, + { + "in": "path", + "name": "node_id", + "required": true, + "schema": { + "maxLength": 128, + "minLength": 1, + "pattern": "^[a-zA-Z0-9][a-zA-Z0-9._:-]*$", + "title": "Node Id", + "type": "string" + } + }, + { + "in": "query", + "name": "limit", + "required": false, + "schema": { + "default": 20, + "title": "Limit", + "type": "integer" + } + }, + { + "in": "header", + "name": "authorization", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Authorization" + } + }, + { + "in": "cookie", + "name": "gewu_session", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Gewu Session" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReadEnvelope" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Get Node History", + "tags": [ + "knowledge" + ] + } + }, + "/v1/projects/{project_id}/sources/{revision_id}": { + "get": { + "operationId": "get_source", + "parameters": [ + { + "in": "path", + "name": "project_id", + "required": true, + "schema": { + "maxLength": 128, + "minLength": 1, + "pattern": "^[a-zA-Z0-9][a-zA-Z0-9._:-]*$", + "title": "Project Id", + "type": "string" + } + }, + { + "in": "path", + "name": "revision_id", + "required": true, + "schema": { + "maxLength": 128, + "minLength": 1, + "pattern": "^[a-zA-Z0-9][a-zA-Z0-9._:-]*$", + "title": "Revision Id", + "type": "string" + } + }, + { + "in": "header", + "name": "authorization", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Authorization" + } + }, + { + "in": "cookie", + "name": "gewu_session", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Gewu Session" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReadEnvelope" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Get Source", + "tags": [ + "knowledge" + ] + } + }, + "/v1/projects/{project_id}/sources/{revision_id}/artifact": { + "get": { + "operationId": "get_source_artifact", + "parameters": [ + { + "in": "path", + "name": "project_id", + "required": true, + "schema": { + "maxLength": 128, + "minLength": 1, + "pattern": "^[a-zA-Z0-9][a-zA-Z0-9._:-]*$", + "title": "Project Id", + "type": "string" + } + }, + { + "in": "path", + "name": "revision_id", + "required": true, + "schema": { + "maxLength": 128, + "minLength": 1, + "pattern": "^[a-zA-Z0-9][a-zA-Z0-9._:-]*$", + "title": "Revision Id", + "type": "string" + } + }, + { + "in": "header", + "name": "authorization", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Authorization" + } + }, + { + "in": "cookie", + "name": "gewu_session", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Gewu Session" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": {} + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Get Source Artifact", + "tags": [ + "knowledge" + ] + } + }, + "/v1/projects/{project_id}/sources/{revision_id}/extractions/{extraction_id}/blocks/{block_id}": { + "get": { + "operationId": "get_source_location", + "parameters": [ + { + "in": "path", + "name": "project_id", + "required": true, + "schema": { + "maxLength": 128, + "minLength": 1, + "pattern": "^[a-zA-Z0-9][a-zA-Z0-9._:-]*$", + "title": "Project Id", + "type": "string" + } + }, + { + "in": "path", + "name": "revision_id", + "required": true, + "schema": { + "maxLength": 128, + "minLength": 1, + "pattern": "^[a-zA-Z0-9][a-zA-Z0-9._:-]*$", + "title": "Revision Id", + "type": "string" + } + }, + { + "in": "path", + "name": "extraction_id", + "required": true, + "schema": { + "title": "Extraction Id", + "type": "string" + } + }, + { + "in": "path", + "name": "block_id", + "required": true, + "schema": { + "maxLength": 128, + "minLength": 1, + "pattern": "^[a-zA-Z0-9][a-zA-Z0-9._:-]*$", + "title": "Block Id", + "type": "string" + } + }, + { + "in": "query", + "name": "offset", + "required": true, + "schema": { + "minimum": 0, + "title": "Offset", + "type": "integer" + } + }, + { + "in": "query", + "name": "length", + "required": true, + "schema": { + "maximum": 12000, + "minimum": 1, + "title": "Length", + "type": "integer" + } + }, + { + "in": "header", + "name": "authorization", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Authorization" + } + }, + { + "in": "cookie", + "name": "gewu_session", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Gewu Session" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "additionalProperties": true, + "title": "Response Get Source Location", + "type": "object" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Get Source Location", + "tags": [ + "knowledge" + ] + } + }, + "/v1/search": { + "post": { + "operationId": "search", + "parameters": [ + { + "in": "header", + "name": "authorization", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Authorization" + } + }, + { + "in": "header", + "name": "X-GEWU-CSRF", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "X-Gewu-Csrf" + } + }, + { + "in": "cookie", + "name": "gewu_session", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Gewu Session" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SearchRequestBody" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReadEnvelope" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Search", + "tags": [ + "knowledge" + ] + } + }, + "/v1/session": { + "delete": { + "operationId": "browser_session_revoke", + "parameters": [ + { + "in": "header", + "name": "X-GEWU-CSRF", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "X-Gewu-Csrf" + } + }, + { + "in": "cookie", + "name": "gewu_session", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Gewu Session" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "additionalProperties": { + "type": "string" + }, + "title": "Response Browser Session Revoke", + "type": "object" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Revoke Browser Session", + "tags": [ + "identity" + ] + }, + "get": { + "operationId": "browser_session_restore", + "parameters": [ + { + "in": "header", + "name": "X-GEWU-Session", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "X-Gewu-Session" + } + }, + { + "in": "cookie", + "name": "gewu_session", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Gewu Session" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "additionalProperties": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "boolean" + } + ] + }, + "title": "Response Browser Session Restore", + "type": "object" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Restore Browser Session", + "tags": [ + "identity" + ] + }, + "post": { + "operationId": "browser_session_create", + "parameters": [ + { + "in": "header", + "name": "authorization", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Authorization" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "additionalProperties": { + "type": "string" + }, + "title": "Response Browser Session Create", + "type": "object" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Create Browser Session", + "tags": [ + "identity" + ] + } + }, + "/v1/sources": { + "post": { + "operationId": "list_sources", + "parameters": [ + { + "in": "header", + "name": "authorization", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Authorization" + } + }, + { + "in": "header", + "name": "X-GEWU-CSRF", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "X-Gewu-Csrf" + } + }, + { + "in": "cookie", + "name": "gewu_session", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Gewu Session" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SourceListRequestBody" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReadEnvelope" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "List Sources", + "tags": [ + "knowledge" + ] + } + }, + "/v1/users/session": { + "post": { + "operationId": "account_login", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LoginBody" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "additionalProperties": { + "type": "string" + }, + "title": "Response Account Login", + "type": "object" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Login", + "tags": [ + "identity" + ] + } + } + } +} diff --git a/schema/v1/records/AcceptanceManifest.schema.json b/schema/v1/records/AcceptanceManifest.schema.json new file mode 100644 index 0000000..0240279 --- /dev/null +++ b/schema/v1/records/AcceptanceManifest.schema.json @@ -0,0 +1,249 @@ +{ + "$defs": { + "ChangedFile": { + "additionalProperties": false, + "properties": { + "content_sha256": { + "pattern": "^[a-f0-9]{64}$", + "title": "Content Sha256", + "type": "string" + }, + "path": { + "maxLength": 500, + "minLength": 1, + "title": "Path", + "type": "string" + }, + "schema_version": { + "const": 1, + "default": 1, + "title": "Schema Version", + "type": "integer" + } + }, + "required": [ + "path", + "content_sha256" + ], + "title": "ChangedFile", + "type": "object" + }, + "OwnerDecisionReference": { + "additionalProperties": false, + "properties": { + "challenge_id": { + "maxLength": 128, + "minLength": 1, + "pattern": "^[a-zA-Z0-9][a-zA-Z0-9._:-]*$", + "title": "Challenge Id", + "type": "string" + }, + "decided_at": { + "format": "date-time", + "title": "Decided At", + "type": "string" + }, + "decision": { + "const": "authorize", + "default": "authorize", + "title": "Decision", + "type": "string" + }, + "decision_digest": { + "pattern": "^[a-f0-9]{64}$", + "title": "Decision Digest", + "type": "string" + }, + "domains": { + "items": { + "maxLength": 128, + "minLength": 1, + "pattern": "^[a-zA-Z0-9][a-zA-Z0-9._:-]*$", + "type": "string" + }, + "maxItems": 100, + "minItems": 1, + "title": "Domains", + "type": "array" + }, + "grant_version": { + "minimum": 1, + "title": "Grant Version", + "type": "integer" + }, + "owner_principal_id": { + "maxLength": 128, + "minLength": 1, + "pattern": "^[a-zA-Z0-9][a-zA-Z0-9._:-]*$", + "title": "Owner Principal Id", + "type": "string" + }, + "owner_session_id": { + "pattern": "^[a-f0-9]{64}$", + "title": "Owner Session Id", + "type": "string" + }, + "rationale": { + "maxLength": 2000, + "minLength": 1, + "title": "Rationale", + "type": "string" + }, + "schema_version": { + "const": 1, + "default": 1, + "title": "Schema Version", + "type": "integer" + } + }, + "required": [ + "challenge_id", + "owner_principal_id", + "owner_session_id", + "decision_digest", + "domains", + "grant_version", + "rationale", + "decided_at" + ], + "title": "OwnerDecisionReference", + "type": "object" + }, + "PolicyBinding": { + "additionalProperties": false, + "properties": { + "policy_id": { + "maxLength": 128, + "minLength": 1, + "pattern": "^[a-zA-Z0-9][a-zA-Z0-9._:-]*$", + "title": "Policy Id", + "type": "string" + }, + "policy_version": { + "minimum": 1, + "title": "Policy Version", + "type": "integer" + }, + "project_id": { + "maxLength": 128, + "minLength": 1, + "pattern": "^[a-zA-Z0-9][a-zA-Z0-9._:-]*$", + "title": "Project Id", + "type": "string" + }, + "schema_version": { + "const": 1, + "default": 1, + "title": "Schema Version", + "type": "integer" + } + }, + "required": [ + "project_id", + "policy_id", + "policy_version" + ], + "title": "PolicyBinding", + "type": "object" + } + }, + "additionalProperties": false, + "properties": { + "accepted_node_ids": { + "default": [], + "items": { + "maxLength": 128, + "minLength": 1, + "pattern": "^[a-zA-Z0-9][a-zA-Z0-9._:-]*$", + "type": "string" + }, + "title": "Accepted Node Ids", + "type": "array" + }, + "actor_id": { + "maxLength": 128, + "minLength": 1, + "pattern": "^[a-zA-Z0-9][a-zA-Z0-9._:-]*$", + "title": "Actor Id", + "type": "string" + }, + "before_revision": { + "pattern": "^(?:[a-f0-9]{40}|[a-f0-9]{64})$", + "title": "Before Revision", + "type": "string" + }, + "changed_files": { + "items": { + "$ref": "#/$defs/ChangedFile" + }, + "maxItems": 200, + "minItems": 1, + "title": "Changed Files", + "type": "array" + }, + "credential_id": { + "maxLength": 128, + "minLength": 1, + "pattern": "^[a-zA-Z0-9][a-zA-Z0-9._:-]*$", + "title": "Credential Id", + "type": "string" + }, + "decided_at": { + "format": "date-time", + "title": "Decided At", + "type": "string" + }, + "owner_decisions": { + "default": [], + "items": { + "$ref": "#/$defs/OwnerDecisionReference" + }, + "maxItems": 100, + "title": "Owner Decisions", + "type": "array" + }, + "payload_digest": { + "pattern": "^[a-f0-9]{64}$", + "title": "Payload Digest", + "type": "string" + }, + "policy_bindings": { + "items": { + "$ref": "#/$defs/PolicyBinding" + }, + "title": "Policy Bindings", + "type": "array" + }, + "proposal_id": { + "maxLength": 128, + "minLength": 1, + "pattern": "^[a-zA-Z0-9][a-zA-Z0-9._:-]*$", + "title": "Proposal Id", + "type": "string" + }, + "proposal_revision": { + "minimum": 1, + "title": "Proposal Revision", + "type": "integer" + }, + "schema_version": { + "const": 1, + "default": 1, + "title": "Schema Version", + "type": "integer" + } + }, + "required": [ + "proposal_id", + "proposal_revision", + "payload_digest", + "before_revision", + "changed_files", + "actor_id", + "credential_id", + "policy_bindings", + "decided_at" + ], + "title": "AcceptanceManifest", + "type": "object" +} diff --git a/schema/v1/records/AutoAcceptPolicy.schema.json b/schema/v1/records/AutoAcceptPolicy.schema.json new file mode 100644 index 0000000..960710e --- /dev/null +++ b/schema/v1/records/AutoAcceptPolicy.schema.json @@ -0,0 +1,109 @@ +{ + "additionalProperties": false, + "properties": { + "automatic_edge_types": { + "default": [], + "items": { + "enum": [ + "solves", + "addresses", + "causes", + "supports", + "contradicts", + "instance_of", + "part_of", + "precedes", + "supersedes", + "compared_with", + "derived_from", + "applies_to" + ], + "type": "string" + }, + "title": "Automatic Edge Types", + "type": "array" + }, + "automatic_node_types": { + "default": [], + "items": { + "enum": [ + "finding", + "problem", + "technique", + "case", + "entity", + "event", + "concept", + "datapoint", + "synthesis" + ], + "type": "string" + }, + "title": "Automatic Node Types", + "type": "array" + }, + "automatic_source_registration": { + "default": false, + "title": "Automatic Source Registration", + "type": "boolean" + }, + "policy_id": { + "maxLength": 128, + "minLength": 1, + "pattern": "^[a-zA-Z0-9][a-zA-Z0-9._:-]*$", + "title": "Policy Id", + "type": "string" + }, + "policy_version": { + "minimum": 1, + "title": "Policy Version", + "type": "integer" + }, + "project_id": { + "maxLength": 128, + "minLength": 1, + "pattern": "^[a-zA-Z0-9][a-zA-Z0-9._:-]*$", + "title": "Project Id", + "type": "string" + }, + "require_owner_decision_for": { + "default": [ + "delete", + "merge", + "promote", + "intent", + "policy" + ], + "items": { + "enum": [ + "delete", + "merge", + "promote", + "intent", + "policy" + ], + "type": "string" + }, + "title": "Require Owner Decision For", + "type": "array" + }, + "require_supported_assessment": { + "default": true, + "title": "Require Supported Assessment", + "type": "boolean" + }, + "schema_version": { + "const": 1, + "default": 1, + "title": "Schema Version", + "type": "integer" + } + }, + "required": [ + "policy_id", + "policy_version", + "project_id" + ], + "title": "AutoAcceptPolicy", + "type": "object" +} diff --git a/schema/v1/records/DomainError.schema.json b/schema/v1/records/DomainError.schema.json new file mode 100644 index 0000000..0460a24 --- /dev/null +++ b/schema/v1/records/DomainError.schema.json @@ -0,0 +1,111 @@ +{ + "$defs": { + "ErrorCode": { + "enum": [ + "SCOPE_REQUIRED", + "OUT_OF_SCOPE", + "UNAUTHORIZED", + "OWNER_DECISION_REQUIRED", + "POLICY_MISSING", + "EVIDENCE_MISMATCH", + "SUPPORT_UNAVAILABLE", + "BASE_CONFLICT", + "IDEMPOTENCY_CONFLICT", + "BUDGET_TOO_SMALL", + "UPLOAD_EXPIRED", + "BUSY", + "RECOVERY_REQUIRED" + ], + "title": "ErrorCode", + "type": "string" + }, + "Finding": { + "additionalProperties": false, + "properties": { + "code": { + "maxLength": 128, + "minLength": 1, + "pattern": "^[a-zA-Z0-9][a-zA-Z0-9._:-]*$", + "title": "Code", + "type": "string" + }, + "message": { + "maxLength": 1000, + "minLength": 1, + "title": "Message", + "type": "string" + }, + "path": { + "anyOf": [ + { + "maxLength": 256, + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Path" + }, + "schema_version": { + "const": 1, + "default": 1, + "title": "Schema Version", + "type": "integer" + } + }, + "required": [ + "code", + "message" + ], + "title": "Finding", + "type": "object" + } + }, + "additionalProperties": false, + "properties": { + "code": { + "$ref": "#/$defs/ErrorCode" + }, + "findings": { + "default": [], + "items": { + "$ref": "#/$defs/Finding" + }, + "title": "Findings", + "type": "array" + }, + "message": { + "maxLength": 1000, + "minLength": 1, + "title": "Message", + "type": "string" + }, + "request_id": { + "maxLength": 128, + "minLength": 1, + "pattern": "^[a-zA-Z0-9][a-zA-Z0-9._:-]*$", + "title": "Request Id", + "type": "string" + }, + "retryable": { + "title": "Retryable", + "type": "boolean" + }, + "schema_version": { + "const": 1, + "default": 1, + "title": "Schema Version", + "type": "integer" + } + }, + "required": [ + "code", + "message", + "retryable", + "request_id" + ], + "title": "DomainError", + "type": "object" +} diff --git a/schema/v1/records/ExtractionRecord.schema.json b/schema/v1/records/ExtractionRecord.schema.json new file mode 100644 index 0000000..a34a09b --- /dev/null +++ b/schema/v1/records/ExtractionRecord.schema.json @@ -0,0 +1,183 @@ +{ + "$defs": { + "ExtractionBlock": { + "additionalProperties": false, + "properties": { + "block_id": { + "maxLength": 128, + "minLength": 1, + "pattern": "^[a-zA-Z0-9][a-zA-Z0-9._:-]*$", + "title": "Block Id", + "type": "string" + }, + "normalization_map": { + "items": { + "maxItems": 2, + "minItems": 2, + "prefixItems": [ + { + "type": "integer" + }, + { + "type": "integer" + } + ], + "type": "array" + }, + "maxItems": 100000, + "title": "Normalization Map", + "type": "array" + }, + "normalized_text": { + "maxLength": 100000, + "title": "Normalized Text", + "type": "string" + }, + "ordinal": { + "minimum": 0, + "title": "Ordinal", + "type": "integer" + }, + "original_text": { + "maxLength": 100000, + "title": "Original Text", + "type": "string" + }, + "page": { + "anyOf": [ + { + "minimum": 1, + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Page" + }, + "schema_version": { + "const": 1, + "default": 1, + "title": "Schema Version", + "type": "integer" + }, + "time_end_ms": { + "anyOf": [ + { + "minimum": 0, + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Time End Ms" + }, + "time_start_ms": { + "anyOf": [ + { + "minimum": 0, + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Time Start Ms" + } + }, + "required": [ + "block_id", + "ordinal", + "original_text", + "normalized_text", + "normalization_map" + ], + "title": "ExtractionBlock", + "type": "object" + } + }, + "additionalProperties": false, + "properties": { + "blocks": { + "items": { + "$ref": "#/$defs/ExtractionBlock" + }, + "maxItems": 100000, + "title": "Blocks", + "type": "array" + }, + "extraction_id": { + "pattern": "^[a-f0-9]{64}$", + "title": "Extraction Id", + "type": "string" + }, + "normalization_profile": { + "const": "unicode-nfc-lf-v1", + "title": "Normalization Profile", + "type": "string" + }, + "parser_config_sha256": { + "pattern": "^[a-f0-9]{64}$", + "title": "Parser Config Sha256", + "type": "string" + }, + "parser_name": { + "maxLength": 100, + "minLength": 1, + "title": "Parser Name", + "type": "string" + }, + "parser_version": { + "maxLength": 100, + "minLength": 1, + "title": "Parser Version", + "type": "string" + }, + "schema_version": { + "const": 1, + "default": 1, + "title": "Schema Version", + "type": "integer" + }, + "source_revision_id": { + "maxLength": 128, + "minLength": 1, + "pattern": "^[a-zA-Z0-9][a-zA-Z0-9._:-]*$", + "title": "Source Revision Id", + "type": "string" + }, + "status": { + "enum": [ + "complete", + "partial", + "unavailable" + ], + "title": "Status", + "type": "string" + }, + "warnings": { + "default": [], + "items": { + "type": "string" + }, + "title": "Warnings", + "type": "array" + } + }, + "required": [ + "extraction_id", + "source_revision_id", + "parser_name", + "parser_version", + "parser_config_sha256", + "normalization_profile", + "status", + "blocks" + ], + "title": "ExtractionRecord", + "type": "object" +} diff --git a/schema/v1/records/KnowledgeNode.schema.json b/schema/v1/records/KnowledgeNode.schema.json new file mode 100644 index 0000000..ab697ac --- /dev/null +++ b/schema/v1/records/KnowledgeNode.schema.json @@ -0,0 +1,693 @@ +{ + "$defs": { + "Claim": { + "additionalProperties": false, + "properties": { + "assessment": { + "$ref": "#/$defs/SupportAssessment" + }, + "claim_id": { + "maxLength": 128, + "minLength": 1, + "pattern": "^[a-zA-Z0-9][a-zA-Z0-9._:-]*$", + "title": "Claim Id", + "type": "string" + }, + "evidence": { + "items": { + "$ref": "#/$defs/EvidenceReference" + }, + "maxItems": 20, + "minItems": 1, + "title": "Evidence", + "type": "array" + }, + "evidence_type": { + "enum": [ + "measured", + "reported", + "opinion", + "vendor_marketing" + ], + "title": "Evidence Type", + "type": "string" + }, + "schema_version": { + "const": 1, + "default": 1, + "title": "Schema Version", + "type": "integer" + }, + "text": { + "maxLength": 4000, + "minLength": 1, + "title": "Text", + "type": "string" + } + }, + "required": [ + "claim_id", + "text", + "evidence_type", + "evidence", + "assessment" + ], + "title": "Claim", + "type": "object" + }, + "Confidence": { + "enum": [ + "low", + "medium", + "high" + ], + "title": "Confidence", + "type": "string" + }, + "EvidenceReference": { + "additionalProperties": false, + "properties": { + "assertion_digest": { + "pattern": "^[a-f0-9]{64}$", + "title": "Assertion Digest", + "type": "string" + }, + "assertion_path": { + "maxLength": 256, + "minLength": 1, + "title": "Assertion Path", + "type": "string" + }, + "extraction_id": { + "pattern": "^[a-f0-9]{64}$", + "title": "Extraction Id", + "type": "string" + }, + "locator": { + "$ref": "#/$defs/Locator" + }, + "normalization_profile": { + "const": "unicode-nfc-lf-v1", + "title": "Normalization Profile", + "type": "string" + }, + "quote": { + "maxLength": 8000, + "minLength": 1, + "title": "Quote", + "type": "string" + }, + "schema_version": { + "const": 1, + "default": 1, + "title": "Schema Version", + "type": "integer" + }, + "source_revision_id": { + "maxLength": 128, + "minLength": 1, + "pattern": "^[a-zA-Z0-9][a-zA-Z0-9._:-]*$", + "title": "Source Revision Id", + "type": "string" + } + }, + "required": [ + "source_revision_id", + "extraction_id", + "assertion_path", + "assertion_digest", + "quote", + "locator", + "normalization_profile" + ], + "title": "EvidenceReference", + "type": "object" + }, + "LensFit": { + "additionalProperties": false, + "properties": { + "horizon": { + "enum": [ + "H1", + "H2", + "H3" + ], + "title": "Horizon", + "type": "string" + }, + "key_question": { + "anyOf": [ + { + "maxLength": 1000, + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Key Question" + }, + "reason": { + "maxLength": 1000, + "minLength": 1, + "title": "Reason", + "type": "string" + }, + "schema_version": { + "const": 1, + "default": 1, + "title": "Schema Version", + "type": "integer" + }, + "score": { + "maximum": 1, + "minimum": 0, + "title": "Score", + "type": "number" + } + }, + "required": [ + "score", + "horizon", + "reason" + ], + "title": "LensFit", + "type": "object" + }, + "Lifecycle": { + "enum": [ + "active", + "superseded" + ], + "title": "Lifecycle", + "type": "string" + }, + "Locator": { + "additionalProperties": false, + "properties": { + "block_id": { + "maxLength": 128, + "minLength": 1, + "pattern": "^[a-zA-Z0-9][a-zA-Z0-9._:-]*$", + "title": "Block Id", + "type": "string" + }, + "end": { + "exclusiveMinimum": 0, + "title": "End", + "type": "integer" + }, + "kind": { + "enum": [ + "text_span", + "pdf_page_span", + "transcript_timestamp_span" + ], + "title": "Kind", + "type": "string" + }, + "page": { + "anyOf": [ + { + "minimum": 1, + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Page" + }, + "schema_version": { + "const": 1, + "default": 1, + "title": "Schema Version", + "type": "integer" + }, + "start": { + "minimum": 0, + "title": "Start", + "type": "integer" + }, + "time_end_ms": { + "anyOf": [ + { + "minimum": 0, + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Time End Ms" + }, + "time_start_ms": { + "anyOf": [ + { + "minimum": 0, + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Time Start Ms" + } + }, + "required": [ + "kind", + "block_id", + "start", + "end" + ], + "title": "Locator", + "type": "object" + }, + "NodeType": { + "enum": [ + "finding", + "problem", + "technique", + "case", + "entity", + "event", + "concept", + "datapoint", + "synthesis" + ], + "title": "NodeType", + "type": "string" + }, + "ReviewState": { + "enum": [ + "draft", + "reviewed" + ], + "title": "ReviewState", + "type": "string" + }, + "SupportAssessment": { + "additionalProperties": false, + "properties": { + "assertion_digest": { + "pattern": "^[a-f0-9]{64}$", + "title": "Assertion Digest", + "type": "string" + }, + "assessment_id": { + "maxLength": 128, + "minLength": 1, + "pattern": "^[a-zA-Z0-9][a-zA-Z0-9._:-]*$", + "title": "Assessment Id", + "type": "string" + }, + "assessor_id": { + "maxLength": 128, + "minLength": 1, + "pattern": "^[a-zA-Z0-9][a-zA-Z0-9._:-]*$", + "title": "Assessor Id", + "type": "string" + }, + "evidence_digests": { + "items": { + "pattern": "^[a-f0-9]{64}$", + "type": "string" + }, + "maxItems": 20, + "minItems": 1, + "title": "Evidence Digests", + "type": "array" + }, + "method": { + "maxLength": 100, + "minLength": 1, + "title": "Method", + "type": "string" + }, + "rationale": { + "maxLength": 4000, + "minLength": 1, + "title": "Rationale", + "type": "string" + }, + "run_id": { + "maxLength": 128, + "minLength": 1, + "pattern": "^[a-zA-Z0-9][a-zA-Z0-9._:-]*$", + "title": "Run Id", + "type": "string" + }, + "schema_version": { + "const": 1, + "default": 1, + "title": "Schema Version", + "type": "integer" + }, + "status": { + "$ref": "#/$defs/SupportStatus" + } + }, + "required": [ + "assessment_id", + "assertion_digest", + "evidence_digests", + "assessor_id", + "run_id", + "method", + "status", + "rationale" + ], + "title": "SupportAssessment", + "type": "object" + }, + "SupportStatus": { + "enum": [ + "supported", + "unsupported", + "unavailable" + ], + "title": "SupportStatus", + "type": "string" + } + }, + "additionalProperties": false, + "properties": { + "agent_id": { + "maxLength": 128, + "minLength": 1, + "pattern": "^[a-zA-Z0-9][a-zA-Z0-9._:-]*$", + "title": "Agent Id", + "type": "string" + }, + "aliases": { + "default": [], + "items": { + "type": "string" + }, + "maxItems": 100, + "title": "Aliases", + "type": "array" + }, + "as_of": { + "anyOf": [ + { + "format": "date-time", + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "As Of" + }, + "assumptions": { + "default": [], + "items": { + "$ref": "#/$defs/Claim" + }, + "maxItems": 100, + "title": "Assumptions", + "type": "array" + }, + "audience": { + "default": [], + "items": { + "type": "string" + }, + "maxItems": 20, + "title": "Audience", + "type": "array" + }, + "claim_support": { + "enum": [ + "pending", + "supported", + "unsupported" + ], + "title": "Claim Support", + "type": "string" + }, + "claims": { + "items": { + "$ref": "#/$defs/Claim" + }, + "maxItems": 100, + "minItems": 1, + "title": "Claims", + "type": "array" + }, + "confidence": { + "$ref": "#/$defs/Confidence" + }, + "created": { + "format": "date-time", + "title": "Created", + "type": "string" + }, + "demographic": { + "anyOf": [ + { + "maxLength": 1000, + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Demographic" + }, + "domain": { + "default": [], + "items": { + "type": "string" + }, + "maxItems": 20, + "title": "Domain", + "type": "array" + }, + "lens_fit": { + "maxProperties": 50, + "patternProperties": { + "^[a-zA-Z0-9][a-zA-Z0-9._:-]*$": { + "$ref": "#/$defs/LensFit" + } + }, + "propertyNames": { + "maxLength": 128, + "minLength": 1 + }, + "title": "Lens Fit", + "type": "object" + }, + "lifecycle": { + "$ref": "#/$defs/Lifecycle" + }, + "limitations": { + "default": [], + "items": { + "$ref": "#/$defs/Claim" + }, + "maxItems": 100, + "title": "Limitations", + "type": "array" + }, + "merged_from": { + "default": [], + "items": { + "maxLength": 128, + "minLength": 1, + "pattern": "^[a-zA-Z0-9][a-zA-Z0-9._:-]*$", + "type": "string" + }, + "maxItems": 100, + "title": "Merged From", + "type": "array" + }, + "model_id": { + "maxLength": 200, + "minLength": 1, + "title": "Model Id", + "type": "string" + }, + "node_id": { + "maxLength": 128, + "minLength": 1, + "pattern": "^[a-zA-Z0-9][a-zA-Z0-9._:-]*$", + "title": "Node Id", + "type": "string" + }, + "node_type": { + "$ref": "#/$defs/NodeType" + }, + "project_id": { + "maxLength": 128, + "minLength": 1, + "pattern": "^[a-zA-Z0-9][a-zA-Z0-9._:-]*$", + "title": "Project Id", + "type": "string" + }, + "reporting_year": { + "anyOf": [ + { + "maximum": 9999, + "minimum": 1, + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Reporting Year" + }, + "review_state": { + "$ref": "#/$defs/ReviewState" + }, + "run_id": { + "maxLength": 128, + "minLength": 1, + "pattern": "^[a-zA-Z0-9][a-zA-Z0-9._:-]*$", + "title": "Run Id", + "type": "string" + }, + "sample_size": { + "anyOf": [ + { + "minimum": 0, + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Sample Size" + }, + "schema_version": { + "const": 1, + "default": 1, + "title": "Schema Version", + "type": "integer" + }, + "stale_after": { + "anyOf": [ + { + "format": "date-time", + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Stale After" + }, + "summary": { + "maxLength": 8000, + "minLength": 1, + "title": "Summary", + "type": "string" + }, + "superseded_by": { + "default": [], + "items": { + "maxLength": 128, + "minLength": 1, + "pattern": "^[a-zA-Z0-9][a-zA-Z0-9._:-]*$", + "type": "string" + }, + "title": "Superseded By", + "type": "array" + }, + "supersedes": { + "default": [], + "items": { + "maxLength": 128, + "minLength": 1, + "pattern": "^[a-zA-Z0-9][a-zA-Z0-9._:-]*$", + "type": "string" + }, + "title": "Supersedes", + "type": "array" + }, + "tags": { + "default": [], + "items": { + "type": "string" + }, + "maxItems": 100, + "title": "Tags", + "type": "array" + }, + "title": { + "maxLength": 300, + "minLength": 1, + "title": "Title", + "type": "string" + }, + "unit": { + "anyOf": [ + { + "maxLength": 100, + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Unit" + }, + "updated": { + "format": "date-time", + "title": "Updated", + "type": "string" + }, + "use_for": { + "default": [], + "items": { + "type": "string" + }, + "maxItems": 20, + "title": "Use For", + "type": "array" + }, + "value": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Value" + } + }, + "required": [ + "node_id", + "project_id", + "node_type", + "title", + "summary", + "created", + "updated", + "review_state", + "lifecycle", + "confidence", + "claim_support", + "run_id", + "agent_id", + "model_id", + "claims" + ], + "title": "KnowledgeNode", + "type": "object" +} diff --git a/schema/v1/records/NeighborRequest.schema.json b/schema/v1/records/NeighborRequest.schema.json new file mode 100644 index 0000000..e83bc1e --- /dev/null +++ b/schema/v1/records/NeighborRequest.schema.json @@ -0,0 +1,137 @@ +{ + "$defs": { + "Scope": { + "additionalProperties": false, + "description": "Project context plus optional project includes; all-project access is deliberate.", + "properties": { + "all_projects": { + "default": false, + "title": "All Projects", + "type": "boolean" + }, + "include_projects": { + "anyOf": [ + { + "items": { + "maxLength": 128, + "minLength": 1, + "pattern": "^[a-zA-Z0-9][a-zA-Z0-9._:-]*$", + "type": "string" + }, + "maxItems": 50, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Include Projects" + }, + "include_shared": { + "default": true, + "title": "Include Shared", + "type": "boolean" + }, + "projects": { + "items": { + "maxLength": 128, + "minLength": 1, + "pattern": "^[a-zA-Z0-9][a-zA-Z0-9._:-]*$", + "type": "string" + }, + "maxItems": 50, + "minItems": 1, + "title": "Projects", + "type": "array" + }, + "schema_version": { + "const": 1, + "default": 1, + "title": "Schema Version", + "type": "integer" + } + }, + "required": [ + "projects" + ], + "title": "Scope", + "type": "object" + } + }, + "additionalProperties": false, + "properties": { + "budget_tokens": { + "maximum": 50000, + "minimum": 256, + "title": "Budget Tokens", + "type": "integer" + }, + "cursor": { + "anyOf": [ + { + "maxLength": 512, + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Cursor" + }, + "edge_types": { + "default": [], + "items": { + "enum": [ + "solves", + "addresses", + "causes", + "supports", + "contradicts", + "instance_of", + "part_of", + "precedes", + "supersedes", + "compared_with", + "derived_from", + "applies_to" + ], + "type": "string" + }, + "maxItems": 12, + "title": "Edge Types", + "type": "array" + }, + "limit": { + "default": 20, + "maximum": 50, + "minimum": 1, + "title": "Limit", + "type": "integer" + }, + "node_id": { + "maxLength": 128, + "minLength": 1, + "pattern": "^[a-zA-Z0-9][a-zA-Z0-9._:-]*$", + "title": "Node Id", + "type": "string" + }, + "schema_version": { + "const": 1, + "default": 1, + "title": "Schema Version", + "type": "integer" + }, + "scope": { + "$ref": "#/$defs/Scope" + } + }, + "required": [ + "node_id", + "scope", + "budget_tokens" + ], + "title": "NeighborRequest", + "type": "object" +} diff --git a/schema/v1/records/ProjectRecord.schema.json b/schema/v1/records/ProjectRecord.schema.json new file mode 100644 index 0000000..0f54972 --- /dev/null +++ b/schema/v1/records/ProjectRecord.schema.json @@ -0,0 +1,120 @@ +{ + "$defs": { + "ProjectStatus": { + "enum": [ + "active", + "paused", + "archived" + ], + "title": "ProjectStatus", + "type": "string" + } + }, + "additionalProperties": false, + "properties": { + "autonomy_policy_id": { + "maxLength": 128, + "minLength": 1, + "pattern": "^[a-zA-Z0-9][a-zA-Z0-9._:-]*$", + "title": "Autonomy Policy Id", + "type": "string" + }, + "default_includes": { + "default": [ + "shared" + ], + "items": { + "maxLength": 128, + "minLength": 1, + "pattern": "^[a-zA-Z0-9][a-zA-Z0-9._:-]*$", + "type": "string" + }, + "title": "Default Includes", + "type": "array" + }, + "description": { + "default": "", + "maxLength": 4000, + "title": "Description", + "type": "string" + }, + "key_questions": { + "items": { + "type": "string" + }, + "maxItems": 30, + "minItems": 1, + "title": "Key Questions", + "type": "array" + }, + "north_star": { + "maxLength": 4000, + "minLength": 1, + "title": "North Star", + "type": "string" + }, + "owners": { + "default": [], + "items": { + "maxLength": 128, + "minLength": 1, + "pattern": "^[a-zA-Z0-9][a-zA-Z0-9._:-]*$", + "type": "string" + }, + "title": "Owners", + "type": "array" + }, + "project_id": { + "maxLength": 128, + "minLength": 1, + "pattern": "^[a-zA-Z0-9][a-zA-Z0-9._:-]*$", + "title": "Project Id", + "type": "string" + }, + "schema_version": { + "const": 1, + "default": 1, + "title": "Schema Version", + "type": "integer" + }, + "status": { + "$ref": "#/$defs/ProjectStatus" + }, + "taxonomy_extensions": { + "additionalProperties": { + "items": { + "type": "string" + }, + "type": "array" + }, + "maxProperties": 100, + "title": "Taxonomy Extensions", + "type": "object" + }, + "title": { + "maxLength": 200, + "minLength": 1, + "title": "Title", + "type": "string" + }, + "watch_list": { + "default": [], + "items": { + "type": "string" + }, + "maxItems": 100, + "title": "Watch List", + "type": "array" + } + }, + "required": [ + "project_id", + "title", + "status", + "north_star", + "key_questions", + "autonomy_policy_id" + ], + "title": "ProjectRecord", + "type": "object" +} diff --git a/schema/v1/records/ProposalReceipt.schema.json b/schema/v1/records/ProposalReceipt.schema.json new file mode 100644 index 0000000..c2017d6 --- /dev/null +++ b/schema/v1/records/ProposalReceipt.schema.json @@ -0,0 +1,152 @@ +{ + "$defs": { + "Finding": { + "additionalProperties": false, + "properties": { + "code": { + "maxLength": 128, + "minLength": 1, + "pattern": "^[a-zA-Z0-9][a-zA-Z0-9._:-]*$", + "title": "Code", + "type": "string" + }, + "message": { + "maxLength": 1000, + "minLength": 1, + "title": "Message", + "type": "string" + }, + "path": { + "anyOf": [ + { + "maxLength": 256, + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Path" + }, + "schema_version": { + "const": 1, + "default": 1, + "title": "Schema Version", + "type": "integer" + } + }, + "required": [ + "code", + "message" + ], + "title": "Finding", + "type": "object" + }, + "ProposalState": { + "enum": [ + "staged", + "validating", + "needs-review", + "accepted", + "rejected", + "withdrawn" + ], + "title": "ProposalState", + "type": "string" + } + }, + "additionalProperties": false, + "properties": { + "accepted_node_ids": { + "default": [], + "items": { + "maxLength": 128, + "minLength": 1, + "pattern": "^[a-zA-Z0-9][a-zA-Z0-9._:-]*$", + "type": "string" + }, + "title": "Accepted Node Ids", + "type": "array" + }, + "commit_id": { + "anyOf": [ + { + "pattern": "^(?:[a-f0-9]{40}|[a-f0-9]{64})$", + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Commit Id" + }, + "decided_at": { + "anyOf": [ + { + "format": "date-time", + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Decided At" + }, + "findings": { + "default": [], + "items": { + "$ref": "#/$defs/Finding" + }, + "title": "Findings", + "type": "array" + }, + "outcome": { + "enum": [ + "accepted", + "held", + "rejected", + "validation-failed" + ], + "title": "Outcome", + "type": "string" + }, + "payload_digest": { + "pattern": "^[a-f0-9]{64}$", + "title": "Payload Digest", + "type": "string" + }, + "proposal_id": { + "maxLength": 128, + "minLength": 1, + "pattern": "^[a-zA-Z0-9][a-zA-Z0-9._:-]*$", + "title": "Proposal Id", + "type": "string" + }, + "revision": { + "minimum": 1, + "title": "Revision", + "type": "integer" + }, + "schema_version": { + "const": 1, + "default": 1, + "title": "Schema Version", + "type": "integer" + }, + "state": { + "$ref": "#/$defs/ProposalState" + } + }, + "required": [ + "proposal_id", + "revision", + "payload_digest", + "state", + "outcome" + ], + "title": "ProposalReceipt", + "type": "object" +} diff --git a/schema/v1/records/ProposalRevision.schema.json b/schema/v1/records/ProposalRevision.schema.json new file mode 100644 index 0000000..ab4b599 --- /dev/null +++ b/schema/v1/records/ProposalRevision.schema.json @@ -0,0 +1,1223 @@ +{ + "$defs": { + "Claim": { + "additionalProperties": false, + "properties": { + "assessment": { + "$ref": "#/$defs/SupportAssessment" + }, + "claim_id": { + "maxLength": 128, + "minLength": 1, + "pattern": "^[a-zA-Z0-9][a-zA-Z0-9._:-]*$", + "title": "Claim Id", + "type": "string" + }, + "evidence": { + "items": { + "$ref": "#/$defs/EvidenceReference" + }, + "maxItems": 20, + "minItems": 1, + "title": "Evidence", + "type": "array" + }, + "evidence_type": { + "enum": [ + "measured", + "reported", + "opinion", + "vendor_marketing" + ], + "title": "Evidence Type", + "type": "string" + }, + "schema_version": { + "const": 1, + "default": 1, + "title": "Schema Version", + "type": "integer" + }, + "text": { + "maxLength": 4000, + "minLength": 1, + "title": "Text", + "type": "string" + } + }, + "required": [ + "claim_id", + "text", + "evidence_type", + "evidence", + "assessment" + ], + "title": "Claim", + "type": "object" + }, + "Confidence": { + "enum": [ + "low", + "medium", + "high" + ], + "title": "Confidence", + "type": "string" + }, + "EdgeEndpoint": { + "additionalProperties": false, + "properties": { + "id": { + "maxLength": 128, + "minLength": 1, + "pattern": "^[a-zA-Z0-9][a-zA-Z0-9._:-]*$", + "title": "Id", + "type": "string" + }, + "kind": { + "enum": [ + "node", + "source", + "project" + ], + "title": "Kind", + "type": "string" + }, + "schema_version": { + "const": 1, + "default": 1, + "title": "Schema Version", + "type": "integer" + } + }, + "required": [ + "kind", + "id" + ], + "title": "EdgeEndpoint", + "type": "object" + }, + "EvidenceReference": { + "additionalProperties": false, + "properties": { + "assertion_digest": { + "pattern": "^[a-f0-9]{64}$", + "title": "Assertion Digest", + "type": "string" + }, + "assertion_path": { + "maxLength": 256, + "minLength": 1, + "title": "Assertion Path", + "type": "string" + }, + "extraction_id": { + "pattern": "^[a-f0-9]{64}$", + "title": "Extraction Id", + "type": "string" + }, + "locator": { + "$ref": "#/$defs/Locator" + }, + "normalization_profile": { + "const": "unicode-nfc-lf-v1", + "title": "Normalization Profile", + "type": "string" + }, + "quote": { + "maxLength": 8000, + "minLength": 1, + "title": "Quote", + "type": "string" + }, + "schema_version": { + "const": 1, + "default": 1, + "title": "Schema Version", + "type": "integer" + }, + "source_revision_id": { + "maxLength": 128, + "minLength": 1, + "pattern": "^[a-zA-Z0-9][a-zA-Z0-9._:-]*$", + "title": "Source Revision Id", + "type": "string" + } + }, + "required": [ + "source_revision_id", + "extraction_id", + "assertion_path", + "assertion_digest", + "quote", + "locator", + "normalization_profile" + ], + "title": "EvidenceReference", + "type": "object" + }, + "KnowledgeNode": { + "additionalProperties": false, + "properties": { + "agent_id": { + "maxLength": 128, + "minLength": 1, + "pattern": "^[a-zA-Z0-9][a-zA-Z0-9._:-]*$", + "title": "Agent Id", + "type": "string" + }, + "aliases": { + "default": [], + "items": { + "type": "string" + }, + "maxItems": 100, + "title": "Aliases", + "type": "array" + }, + "as_of": { + "anyOf": [ + { + "format": "date-time", + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "As Of" + }, + "assumptions": { + "default": [], + "items": { + "$ref": "#/$defs/Claim" + }, + "maxItems": 100, + "title": "Assumptions", + "type": "array" + }, + "audience": { + "default": [], + "items": { + "type": "string" + }, + "maxItems": 20, + "title": "Audience", + "type": "array" + }, + "claim_support": { + "enum": [ + "pending", + "supported", + "unsupported" + ], + "title": "Claim Support", + "type": "string" + }, + "claims": { + "items": { + "$ref": "#/$defs/Claim" + }, + "maxItems": 100, + "minItems": 1, + "title": "Claims", + "type": "array" + }, + "confidence": { + "$ref": "#/$defs/Confidence" + }, + "created": { + "format": "date-time", + "title": "Created", + "type": "string" + }, + "demographic": { + "anyOf": [ + { + "maxLength": 1000, + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Demographic" + }, + "domain": { + "default": [], + "items": { + "type": "string" + }, + "maxItems": 20, + "title": "Domain", + "type": "array" + }, + "lens_fit": { + "maxProperties": 50, + "patternProperties": { + "^[a-zA-Z0-9][a-zA-Z0-9._:-]*$": { + "$ref": "#/$defs/LensFit" + } + }, + "propertyNames": { + "maxLength": 128, + "minLength": 1 + }, + "title": "Lens Fit", + "type": "object" + }, + "lifecycle": { + "$ref": "#/$defs/Lifecycle" + }, + "limitations": { + "default": [], + "items": { + "$ref": "#/$defs/Claim" + }, + "maxItems": 100, + "title": "Limitations", + "type": "array" + }, + "merged_from": { + "default": [], + "items": { + "maxLength": 128, + "minLength": 1, + "pattern": "^[a-zA-Z0-9][a-zA-Z0-9._:-]*$", + "type": "string" + }, + "maxItems": 100, + "title": "Merged From", + "type": "array" + }, + "model_id": { + "maxLength": 200, + "minLength": 1, + "title": "Model Id", + "type": "string" + }, + "node_id": { + "maxLength": 128, + "minLength": 1, + "pattern": "^[a-zA-Z0-9][a-zA-Z0-9._:-]*$", + "title": "Node Id", + "type": "string" + }, + "node_type": { + "$ref": "#/$defs/NodeType" + }, + "project_id": { + "maxLength": 128, + "minLength": 1, + "pattern": "^[a-zA-Z0-9][a-zA-Z0-9._:-]*$", + "title": "Project Id", + "type": "string" + }, + "reporting_year": { + "anyOf": [ + { + "maximum": 9999, + "minimum": 1, + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Reporting Year" + }, + "review_state": { + "$ref": "#/$defs/ReviewState" + }, + "run_id": { + "maxLength": 128, + "minLength": 1, + "pattern": "^[a-zA-Z0-9][a-zA-Z0-9._:-]*$", + "title": "Run Id", + "type": "string" + }, + "sample_size": { + "anyOf": [ + { + "minimum": 0, + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Sample Size" + }, + "schema_version": { + "const": 1, + "default": 1, + "title": "Schema Version", + "type": "integer" + }, + "stale_after": { + "anyOf": [ + { + "format": "date-time", + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Stale After" + }, + "summary": { + "maxLength": 8000, + "minLength": 1, + "title": "Summary", + "type": "string" + }, + "superseded_by": { + "default": [], + "items": { + "maxLength": 128, + "minLength": 1, + "pattern": "^[a-zA-Z0-9][a-zA-Z0-9._:-]*$", + "type": "string" + }, + "title": "Superseded By", + "type": "array" + }, + "supersedes": { + "default": [], + "items": { + "maxLength": 128, + "minLength": 1, + "pattern": "^[a-zA-Z0-9][a-zA-Z0-9._:-]*$", + "type": "string" + }, + "title": "Supersedes", + "type": "array" + }, + "tags": { + "default": [], + "items": { + "type": "string" + }, + "maxItems": 100, + "title": "Tags", + "type": "array" + }, + "title": { + "maxLength": 300, + "minLength": 1, + "title": "Title", + "type": "string" + }, + "unit": { + "anyOf": [ + { + "maxLength": 100, + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Unit" + }, + "updated": { + "format": "date-time", + "title": "Updated", + "type": "string" + }, + "use_for": { + "default": [], + "items": { + "type": "string" + }, + "maxItems": 20, + "title": "Use For", + "type": "array" + }, + "value": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Value" + } + }, + "required": [ + "node_id", + "project_id", + "node_type", + "title", + "summary", + "created", + "updated", + "review_state", + "lifecycle", + "confidence", + "claim_support", + "run_id", + "agent_id", + "model_id", + "claims" + ], + "title": "KnowledgeNode", + "type": "object" + }, + "LensFit": { + "additionalProperties": false, + "properties": { + "horizon": { + "enum": [ + "H1", + "H2", + "H3" + ], + "title": "Horizon", + "type": "string" + }, + "key_question": { + "anyOf": [ + { + "maxLength": 1000, + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Key Question" + }, + "reason": { + "maxLength": 1000, + "minLength": 1, + "title": "Reason", + "type": "string" + }, + "schema_version": { + "const": 1, + "default": 1, + "title": "Schema Version", + "type": "integer" + }, + "score": { + "maximum": 1, + "minimum": 0, + "title": "Score", + "type": "number" + } + }, + "required": [ + "score", + "horizon", + "reason" + ], + "title": "LensFit", + "type": "object" + }, + "Lifecycle": { + "enum": [ + "active", + "superseded" + ], + "title": "Lifecycle", + "type": "string" + }, + "Locator": { + "additionalProperties": false, + "properties": { + "block_id": { + "maxLength": 128, + "minLength": 1, + "pattern": "^[a-zA-Z0-9][a-zA-Z0-9._:-]*$", + "title": "Block Id", + "type": "string" + }, + "end": { + "exclusiveMinimum": 0, + "title": "End", + "type": "integer" + }, + "kind": { + "enum": [ + "text_span", + "pdf_page_span", + "transcript_timestamp_span" + ], + "title": "Kind", + "type": "string" + }, + "page": { + "anyOf": [ + { + "minimum": 1, + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Page" + }, + "schema_version": { + "const": 1, + "default": 1, + "title": "Schema Version", + "type": "integer" + }, + "start": { + "minimum": 0, + "title": "Start", + "type": "integer" + }, + "time_end_ms": { + "anyOf": [ + { + "minimum": 0, + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Time End Ms" + }, + "time_start_ms": { + "anyOf": [ + { + "minimum": 0, + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Time Start Ms" + } + }, + "required": [ + "kind", + "block_id", + "start", + "end" + ], + "title": "Locator", + "type": "object" + }, + "NodeType": { + "enum": [ + "finding", + "problem", + "technique", + "case", + "entity", + "event", + "concept", + "datapoint", + "synthesis" + ], + "title": "NodeType", + "type": "string" + }, + "ProposalOperation": { + "additionalProperties": false, + "properties": { + "edge": { + "anyOf": [ + { + "$ref": "#/$defs/TypedEdge" + }, + { + "type": "null" + } + ], + "default": null + }, + "node": { + "anyOf": [ + { + "$ref": "#/$defs/KnowledgeNode" + }, + { + "type": "null" + } + ], + "default": null + }, + "operation": { + "enum": [ + "register_source", + "upsert_node", + "add_edge", + "flag_stale" + ], + "title": "Operation", + "type": "string" + }, + "schema_version": { + "const": 1, + "default": 1, + "title": "Schema Version", + "type": "integer" + }, + "source": { + "anyOf": [ + { + "$ref": "#/$defs/SourceRevisionRecord" + }, + { + "type": "null" + } + ], + "default": null + }, + "target_id": { + "anyOf": [ + { + "maxLength": 128, + "minLength": 1, + "pattern": "^[a-zA-Z0-9][a-zA-Z0-9._:-]*$", + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Target Id" + } + }, + "required": [ + "operation" + ], + "title": "ProposalOperation", + "type": "object" + }, + "ReliabilityGrade": { + "enum": [ + "low", + "medium", + "high" + ], + "title": "ReliabilityGrade", + "type": "string" + }, + "ReviewState": { + "enum": [ + "draft", + "reviewed" + ], + "title": "ReviewState", + "type": "string" + }, + "Scope": { + "additionalProperties": false, + "description": "Project context plus optional project includes; all-project access is deliberate.", + "properties": { + "all_projects": { + "default": false, + "title": "All Projects", + "type": "boolean" + }, + "include_projects": { + "anyOf": [ + { + "items": { + "maxLength": 128, + "minLength": 1, + "pattern": "^[a-zA-Z0-9][a-zA-Z0-9._:-]*$", + "type": "string" + }, + "maxItems": 50, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Include Projects" + }, + "include_shared": { + "default": true, + "title": "Include Shared", + "type": "boolean" + }, + "projects": { + "items": { + "maxLength": 128, + "minLength": 1, + "pattern": "^[a-zA-Z0-9][a-zA-Z0-9._:-]*$", + "type": "string" + }, + "maxItems": 50, + "minItems": 1, + "title": "Projects", + "type": "array" + }, + "schema_version": { + "const": 1, + "default": 1, + "title": "Schema Version", + "type": "integer" + } + }, + "required": [ + "projects" + ], + "title": "Scope", + "type": "object" + }, + "SourceRevisionRecord": { + "additionalProperties": false, + "properties": { + "archive_status": { + "enum": [ + "archived", + "reference-only", + "capture-failed" + ], + "title": "Archive Status", + "type": "string" + }, + "artifact_sha256": { + "anyOf": [ + { + "pattern": "^[a-f0-9]{64}$", + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Artifact Sha256" + }, + "authors": { + "default": [], + "items": { + "type": "string" + }, + "maxItems": 50, + "title": "Authors", + "type": "array" + }, + "byte_length": { + "minimum": 0, + "title": "Byte Length", + "type": "integer" + }, + "capture_method": { + "default": "unspecified", + "enum": [ + "unspecified", + "local-file", + "url-fetch", + "pasted-text", + "notebooklm-export", + "transcript" + ], + "title": "Capture Method", + "type": "string" + }, + "captured_at": { + "format": "date-time", + "title": "Captured At", + "type": "string" + }, + "final_url": { + "anyOf": [ + { + "maxLength": 4000, + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Final Url" + }, + "ingested_for": { + "items": { + "maxLength": 128, + "minLength": 1, + "pattern": "^[a-zA-Z0-9][a-zA-Z0-9._:-]*$", + "type": "string" + }, + "maxItems": 50, + "minItems": 1, + "title": "Ingested For", + "type": "array" + }, + "license": { + "maxLength": 500, + "minLength": 1, + "title": "License", + "type": "string" + }, + "media_type": { + "maxLength": 128, + "minLength": 1, + "title": "Media Type", + "type": "string" + }, + "original_url": { + "anyOf": [ + { + "maxLength": 4000, + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Original Url" + }, + "publication_date": { + "anyOf": [ + { + "format": "date-time", + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Publication Date" + }, + "publisher": { + "anyOf": [ + { + "maxLength": 300, + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Publisher" + }, + "reliability_grade": { + "$ref": "#/$defs/ReliabilityGrade" + }, + "revision_id": { + "maxLength": 128, + "minLength": 1, + "pattern": "^[a-zA-Z0-9][a-zA-Z0-9._:-]*$", + "title": "Revision Id", + "type": "string" + }, + "schema_version": { + "const": 1, + "default": 1, + "title": "Schema Version", + "type": "integer" + }, + "source_id": { + "maxLength": 128, + "minLength": 1, + "pattern": "^[a-zA-Z0-9][a-zA-Z0-9._:-]*$", + "title": "Source Id", + "type": "string" + }, + "source_type": { + "enum": [ + "paper", + "review", + "press", + "vendor", + "internal", + "talk", + "export", + "other" + ], + "title": "Source Type", + "type": "string" + }, + "title": { + "maxLength": 500, + "minLength": 1, + "title": "Title", + "type": "string" + } + }, + "required": [ + "source_id", + "revision_id", + "media_type", + "byte_length", + "captured_at", + "title", + "license", + "source_type", + "reliability_grade", + "ingested_for", + "archive_status" + ], + "title": "SourceRevisionRecord", + "type": "object" + }, + "SupportAssessment": { + "additionalProperties": false, + "properties": { + "assertion_digest": { + "pattern": "^[a-f0-9]{64}$", + "title": "Assertion Digest", + "type": "string" + }, + "assessment_id": { + "maxLength": 128, + "minLength": 1, + "pattern": "^[a-zA-Z0-9][a-zA-Z0-9._:-]*$", + "title": "Assessment Id", + "type": "string" + }, + "assessor_id": { + "maxLength": 128, + "minLength": 1, + "pattern": "^[a-zA-Z0-9][a-zA-Z0-9._:-]*$", + "title": "Assessor Id", + "type": "string" + }, + "evidence_digests": { + "items": { + "pattern": "^[a-f0-9]{64}$", + "type": "string" + }, + "maxItems": 20, + "minItems": 1, + "title": "Evidence Digests", + "type": "array" + }, + "method": { + "maxLength": 100, + "minLength": 1, + "title": "Method", + "type": "string" + }, + "rationale": { + "maxLength": 4000, + "minLength": 1, + "title": "Rationale", + "type": "string" + }, + "run_id": { + "maxLength": 128, + "minLength": 1, + "pattern": "^[a-zA-Z0-9][a-zA-Z0-9._:-]*$", + "title": "Run Id", + "type": "string" + }, + "schema_version": { + "const": 1, + "default": 1, + "title": "Schema Version", + "type": "integer" + }, + "status": { + "$ref": "#/$defs/SupportStatus" + } + }, + "required": [ + "assessment_id", + "assertion_digest", + "evidence_digests", + "assessor_id", + "run_id", + "method", + "status", + "rationale" + ], + "title": "SupportAssessment", + "type": "object" + }, + "SupportStatus": { + "enum": [ + "supported", + "unsupported", + "unavailable" + ], + "title": "SupportStatus", + "type": "string" + }, + "TypedEdge": { + "additionalProperties": false, + "properties": { + "conditions": { + "anyOf": [ + { + "maxLength": 2000, + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Conditions" + }, + "edge_id": { + "maxLength": 128, + "minLength": 1, + "pattern": "^[a-zA-Z0-9][a-zA-Z0-9._:-]*$", + "title": "Edge Id", + "type": "string" + }, + "edge_type": { + "enum": [ + "solves", + "addresses", + "causes", + "supports", + "contradicts", + "instance_of", + "part_of", + "precedes", + "supersedes", + "compared_with", + "derived_from", + "applies_to" + ], + "title": "Edge Type", + "type": "string" + }, + "evidence": { + "default": [], + "items": { + "$ref": "#/$defs/EvidenceReference" + }, + "title": "Evidence", + "type": "array" + }, + "interpretation_rationale": { + "anyOf": [ + { + "maxLength": 4000, + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Interpretation Rationale" + }, + "schema_version": { + "const": 1, + "default": 1, + "title": "Schema Version", + "type": "integer" + }, + "source": { + "$ref": "#/$defs/EdgeEndpoint" + }, + "target": { + "$ref": "#/$defs/EdgeEndpoint" + } + }, + "required": [ + "edge_id", + "edge_type", + "source", + "target" + ], + "title": "TypedEdge", + "type": "object" + } + }, + "additionalProperties": false, + "properties": { + "base_commit": { + "pattern": "^(?:[a-f0-9]{40}|[a-f0-9]{64})$", + "title": "Base Commit", + "type": "string" + }, + "initiating_project_id": { + "maxLength": 128, + "minLength": 1, + "pattern": "^[a-zA-Z0-9][a-zA-Z0-9._:-]*$", + "title": "Initiating Project Id", + "type": "string" + }, + "operations": { + "items": { + "$ref": "#/$defs/ProposalOperation" + }, + "maxItems": 100, + "minItems": 1, + "title": "Operations", + "type": "array" + }, + "payload_digest": { + "pattern": "^[a-f0-9]{64}$", + "title": "Payload Digest", + "type": "string" + }, + "proposal_id": { + "maxLength": 128, + "minLength": 1, + "pattern": "^[a-zA-Z0-9][a-zA-Z0-9._:-]*$", + "title": "Proposal Id", + "type": "string" + }, + "proposer_id": { + "maxLength": 128, + "minLength": 1, + "pattern": "^[a-zA-Z0-9][a-zA-Z0-9._:-]*$", + "title": "Proposer Id", + "type": "string" + }, + "revision": { + "minimum": 1, + "title": "Revision", + "type": "integer" + }, + "run_id": { + "maxLength": 128, + "minLength": 1, + "pattern": "^[a-zA-Z0-9][a-zA-Z0-9._:-]*$", + "title": "Run Id", + "type": "string" + }, + "schema_version": { + "const": 1, + "default": 1, + "title": "Schema Version", + "type": "integer" + }, + "scope": { + "$ref": "#/$defs/Scope" + } + }, + "required": [ + "proposal_id", + "revision", + "initiating_project_id", + "scope", + "base_commit", + "payload_digest", + "proposer_id", + "run_id", + "operations" + ], + "title": "ProposalRevision", + "type": "object" +} diff --git a/schema/v1/records/ProposalStatus.schema.json b/schema/v1/records/ProposalStatus.schema.json new file mode 100644 index 0000000..a11ec39 --- /dev/null +++ b/schema/v1/records/ProposalStatus.schema.json @@ -0,0 +1,257 @@ +{ + "$defs": { + "Finding": { + "additionalProperties": false, + "properties": { + "code": { + "maxLength": 128, + "minLength": 1, + "pattern": "^[a-zA-Z0-9][a-zA-Z0-9._:-]*$", + "title": "Code", + "type": "string" + }, + "message": { + "maxLength": 1000, + "minLength": 1, + "title": "Message", + "type": "string" + }, + "path": { + "anyOf": [ + { + "maxLength": 256, + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Path" + }, + "schema_version": { + "const": 1, + "default": 1, + "title": "Schema Version", + "type": "integer" + } + }, + "required": [ + "code", + "message" + ], + "title": "Finding", + "type": "object" + }, + "ProposalReceipt": { + "additionalProperties": false, + "properties": { + "accepted_node_ids": { + "default": [], + "items": { + "maxLength": 128, + "minLength": 1, + "pattern": "^[a-zA-Z0-9][a-zA-Z0-9._:-]*$", + "type": "string" + }, + "title": "Accepted Node Ids", + "type": "array" + }, + "commit_id": { + "anyOf": [ + { + "pattern": "^(?:[a-f0-9]{40}|[a-f0-9]{64})$", + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Commit Id" + }, + "decided_at": { + "anyOf": [ + { + "format": "date-time", + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Decided At" + }, + "findings": { + "default": [], + "items": { + "$ref": "#/$defs/Finding" + }, + "title": "Findings", + "type": "array" + }, + "outcome": { + "enum": [ + "accepted", + "held", + "rejected", + "validation-failed" + ], + "title": "Outcome", + "type": "string" + }, + "payload_digest": { + "pattern": "^[a-f0-9]{64}$", + "title": "Payload Digest", + "type": "string" + }, + "proposal_id": { + "maxLength": 128, + "minLength": 1, + "pattern": "^[a-zA-Z0-9][a-zA-Z0-9._:-]*$", + "title": "Proposal Id", + "type": "string" + }, + "revision": { + "minimum": 1, + "title": "Revision", + "type": "integer" + }, + "schema_version": { + "const": 1, + "default": 1, + "title": "Schema Version", + "type": "integer" + }, + "state": { + "$ref": "#/$defs/ProposalState" + } + }, + "required": [ + "proposal_id", + "revision", + "payload_digest", + "state", + "outcome" + ], + "title": "ProposalReceipt", + "type": "object" + }, + "ProposalState": { + "enum": [ + "staged", + "validating", + "needs-review", + "accepted", + "rejected", + "withdrawn" + ], + "title": "ProposalState", + "type": "string" + } + }, + "additionalProperties": false, + "properties": { + "canonical_revision": { + "anyOf": [ + { + "pattern": "^(?:[a-f0-9]{40}|[a-f0-9]{64})$", + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Canonical Revision" + }, + "eligible_for_auto_accept": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Eligible For Auto Accept" + }, + "findings": { + "default": [], + "items": { + "$ref": "#/$defs/Finding" + }, + "maxItems": 200, + "title": "Findings", + "type": "array" + }, + "lifecycle": { + "enum": [ + "staged", + "evaluated", + "publishing", + "published-unreconciled", + "accepted", + "withdrawn" + ], + "title": "Lifecycle", + "type": "string" + }, + "proposal_id": { + "maxLength": 128, + "minLength": 1, + "pattern": "^[a-zA-Z0-9][a-zA-Z0-9._:-]*$", + "title": "Proposal Id", + "type": "string" + }, + "receipt": { + "anyOf": [ + { + "$ref": "#/$defs/ProposalReceipt" + }, + { + "type": "null" + } + ], + "default": null + }, + "revision": { + "minimum": 1, + "title": "Revision", + "type": "integer" + }, + "schema_version": { + "const": 1, + "default": 1, + "title": "Schema Version", + "type": "integer" + }, + "validation_status": { + "anyOf": [ + { + "enum": [ + "eligible", + "needs-review", + "invalid", + "conflict" + ], + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Validation Status" + } + }, + "required": [ + "proposal_id", + "revision", + "lifecycle" + ], + "title": "ProposalStatus", + "type": "object" +} diff --git a/schema/v1/records/ReadEnvelope.schema.json b/schema/v1/records/ReadEnvelope.schema.json new file mode 100644 index 0000000..f0d8432 --- /dev/null +++ b/schema/v1/records/ReadEnvelope.schema.json @@ -0,0 +1,399 @@ +{ + "$defs": { + "BudgetProfile": { + "additionalProperties": false, + "properties": { + "encoding": { + "maxLength": 100, + "minLength": 1, + "title": "Encoding", + "type": "string" + }, + "schema_version": { + "const": 1, + "default": 1, + "title": "Schema Version", + "type": "integer" + }, + "tokenizer": { + "maxLength": 100, + "minLength": 1, + "title": "Tokenizer", + "type": "string" + } + }, + "required": [ + "tokenizer", + "encoding" + ], + "title": "BudgetProfile", + "type": "object" + }, + "EvidenceReference": { + "additionalProperties": false, + "properties": { + "assertion_digest": { + "pattern": "^[a-f0-9]{64}$", + "title": "Assertion Digest", + "type": "string" + }, + "assertion_path": { + "maxLength": 256, + "minLength": 1, + "title": "Assertion Path", + "type": "string" + }, + "extraction_id": { + "pattern": "^[a-f0-9]{64}$", + "title": "Extraction Id", + "type": "string" + }, + "locator": { + "$ref": "#/$defs/Locator" + }, + "normalization_profile": { + "const": "unicode-nfc-lf-v1", + "title": "Normalization Profile", + "type": "string" + }, + "quote": { + "maxLength": 8000, + "minLength": 1, + "title": "Quote", + "type": "string" + }, + "schema_version": { + "const": 1, + "default": 1, + "title": "Schema Version", + "type": "integer" + }, + "source_revision_id": { + "maxLength": 128, + "minLength": 1, + "pattern": "^[a-zA-Z0-9][a-zA-Z0-9._:-]*$", + "title": "Source Revision Id", + "type": "string" + } + }, + "required": [ + "source_revision_id", + "extraction_id", + "assertion_path", + "assertion_digest", + "quote", + "locator", + "normalization_profile" + ], + "title": "EvidenceReference", + "type": "object" + }, + "Finding": { + "additionalProperties": false, + "properties": { + "code": { + "maxLength": 128, + "minLength": 1, + "pattern": "^[a-zA-Z0-9][a-zA-Z0-9._:-]*$", + "title": "Code", + "type": "string" + }, + "message": { + "maxLength": 1000, + "minLength": 1, + "title": "Message", + "type": "string" + }, + "path": { + "anyOf": [ + { + "maxLength": 256, + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Path" + }, + "schema_version": { + "const": 1, + "default": 1, + "title": "Schema Version", + "type": "integer" + } + }, + "required": [ + "code", + "message" + ], + "title": "Finding", + "type": "object" + }, + "Locator": { + "additionalProperties": false, + "properties": { + "block_id": { + "maxLength": 128, + "minLength": 1, + "pattern": "^[a-zA-Z0-9][a-zA-Z0-9._:-]*$", + "title": "Block Id", + "type": "string" + }, + "end": { + "exclusiveMinimum": 0, + "title": "End", + "type": "integer" + }, + "kind": { + "enum": [ + "text_span", + "pdf_page_span", + "transcript_timestamp_span" + ], + "title": "Kind", + "type": "string" + }, + "page": { + "anyOf": [ + { + "minimum": 1, + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Page" + }, + "schema_version": { + "const": 1, + "default": 1, + "title": "Schema Version", + "type": "integer" + }, + "start": { + "minimum": 0, + "title": "Start", + "type": "integer" + }, + "time_end_ms": { + "anyOf": [ + { + "minimum": 0, + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Time End Ms" + }, + "time_start_ms": { + "anyOf": [ + { + "minimum": 0, + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Time Start Ms" + } + }, + "required": [ + "kind", + "block_id", + "start", + "end" + ], + "title": "Locator", + "type": "object" + }, + "Scope": { + "additionalProperties": false, + "description": "Project context plus optional project includes; all-project access is deliberate.", + "properties": { + "all_projects": { + "default": false, + "title": "All Projects", + "type": "boolean" + }, + "include_projects": { + "anyOf": [ + { + "items": { + "maxLength": 128, + "minLength": 1, + "pattern": "^[a-zA-Z0-9][a-zA-Z0-9._:-]*$", + "type": "string" + }, + "maxItems": 50, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Include Projects" + }, + "include_shared": { + "default": true, + "title": "Include Shared", + "type": "boolean" + }, + "projects": { + "items": { + "maxLength": 128, + "minLength": 1, + "pattern": "^[a-zA-Z0-9][a-zA-Z0-9._:-]*$", + "type": "string" + }, + "maxItems": 50, + "minItems": 1, + "title": "Projects", + "type": "array" + }, + "schema_version": { + "const": 1, + "default": 1, + "title": "Schema Version", + "type": "integer" + } + }, + "required": [ + "projects" + ], + "title": "Scope", + "type": "object" + } + }, + "additionalProperties": false, + "properties": { + "api_version": { + "const": "v1", + "default": "v1", + "title": "Api Version", + "type": "string" + }, + "budget_profile": { + "$ref": "#/$defs/BudgetProfile" + }, + "canonical_revision": { + "anyOf": [ + { + "pattern": "^(?:[a-f0-9]{40}|[a-f0-9]{64})$", + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Canonical Revision" + }, + "citations": { + "default": [], + "items": { + "$ref": "#/$defs/EvidenceReference" + }, + "title": "Citations", + "type": "array" + }, + "data": { + "items": { + "additionalProperties": true, + "type": "object" + }, + "title": "Data", + "type": "array" + }, + "index_revision": { + "anyOf": [ + { + "pattern": "^(?:[a-f0-9]{40}|[a-f0-9]{64})$", + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Index Revision" + }, + "next_cursor": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Next Cursor" + }, + "omissions": { + "default": [], + "items": { + "$ref": "#/$defs/Finding" + }, + "title": "Omissions", + "type": "array" + }, + "request_id": { + "maxLength": 128, + "minLength": 1, + "pattern": "^[a-zA-Z0-9][a-zA-Z0-9._:-]*$", + "title": "Request Id", + "type": "string" + }, + "resolved_scope": { + "$ref": "#/$defs/Scope" + }, + "retrieval_mode": { + "enum": [ + "keyword", + "hybrid", + "canonical" + ], + "title": "Retrieval Mode", + "type": "string" + }, + "schema_version": { + "const": 1, + "default": 1, + "title": "Schema Version", + "type": "integer" + }, + "tokens_used": { + "minimum": 0, + "title": "Tokens Used", + "type": "integer" + }, + "warnings": { + "default": [], + "items": { + "$ref": "#/$defs/Finding" + }, + "title": "Warnings", + "type": "array" + } + }, + "required": [ + "request_id", + "resolved_scope", + "data", + "canonical_revision", + "index_revision", + "retrieval_mode", + "budget_profile", + "tokens_used" + ], + "title": "ReadEnvelope", + "type": "object" +} diff --git a/schema/v1/records/RunCheckpointRequest.schema.json b/schema/v1/records/RunCheckpointRequest.schema.json new file mode 100644 index 0000000..fd197be --- /dev/null +++ b/schema/v1/records/RunCheckpointRequest.schema.json @@ -0,0 +1,503 @@ +{ + "$defs": { + "Finding": { + "additionalProperties": false, + "properties": { + "code": { + "maxLength": 128, + "minLength": 1, + "pattern": "^[a-zA-Z0-9][a-zA-Z0-9._:-]*$", + "title": "Code", + "type": "string" + }, + "message": { + "maxLength": 1000, + "minLength": 1, + "title": "Message", + "type": "string" + }, + "path": { + "anyOf": [ + { + "maxLength": 256, + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Path" + }, + "schema_version": { + "const": 1, + "default": 1, + "title": "Schema Version", + "type": "integer" + } + }, + "required": [ + "code", + "message" + ], + "title": "Finding", + "type": "object" + }, + "ProposalReceipt": { + "additionalProperties": false, + "properties": { + "accepted_node_ids": { + "default": [], + "items": { + "maxLength": 128, + "minLength": 1, + "pattern": "^[a-zA-Z0-9][a-zA-Z0-9._:-]*$", + "type": "string" + }, + "title": "Accepted Node Ids", + "type": "array" + }, + "commit_id": { + "anyOf": [ + { + "pattern": "^(?:[a-f0-9]{40}|[a-f0-9]{64})$", + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Commit Id" + }, + "decided_at": { + "anyOf": [ + { + "format": "date-time", + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Decided At" + }, + "findings": { + "default": [], + "items": { + "$ref": "#/$defs/Finding" + }, + "title": "Findings", + "type": "array" + }, + "outcome": { + "enum": [ + "accepted", + "held", + "rejected", + "validation-failed" + ], + "title": "Outcome", + "type": "string" + }, + "payload_digest": { + "pattern": "^[a-f0-9]{64}$", + "title": "Payload Digest", + "type": "string" + }, + "proposal_id": { + "maxLength": 128, + "minLength": 1, + "pattern": "^[a-zA-Z0-9][a-zA-Z0-9._:-]*$", + "title": "Proposal Id", + "type": "string" + }, + "revision": { + "minimum": 1, + "title": "Revision", + "type": "integer" + }, + "schema_version": { + "const": 1, + "default": 1, + "title": "Schema Version", + "type": "integer" + }, + "state": { + "$ref": "#/$defs/ProposalState" + } + }, + "required": [ + "proposal_id", + "revision", + "payload_digest", + "state", + "outcome" + ], + "title": "ProposalReceipt", + "type": "object" + }, + "ProposalState": { + "enum": [ + "staged", + "validating", + "needs-review", + "accepted", + "rejected", + "withdrawn" + ], + "title": "ProposalState", + "type": "string" + }, + "RunCoverage": { + "additionalProperties": false, + "properties": { + "excluded": { + "default": [], + "items": { + "$ref": "#/$defs/RunCoverageItem" + }, + "maxItems": 10000, + "title": "Excluded", + "type": "array" + }, + "included": { + "default": [], + "items": { + "type": "string" + }, + "maxItems": 10000, + "title": "Included", + "type": "array" + }, + "schema_version": { + "const": 1, + "default": 1, + "title": "Schema Version", + "type": "integer" + }, + "selected": { + "default": [], + "items": { + "type": "string" + }, + "maxItems": 10000, + "title": "Selected", + "type": "array" + }, + "skipped": { + "default": [], + "items": { + "$ref": "#/$defs/RunCoverageItem" + }, + "maxItems": 10000, + "title": "Skipped", + "type": "array" + } + }, + "title": "RunCoverage", + "type": "object" + }, + "RunCoverageItem": { + "additionalProperties": false, + "properties": { + "item": { + "maxLength": 500, + "minLength": 1, + "title": "Item", + "type": "string" + }, + "reason": { + "maxLength": 1000, + "minLength": 1, + "title": "Reason", + "type": "string" + }, + "schema_version": { + "const": 1, + "default": 1, + "title": "Schema Version", + "type": "integer" + } + }, + "required": [ + "item", + "reason" + ], + "title": "RunCoverageItem", + "type": "object" + }, + "RunOperationReference": { + "additionalProperties": false, + "properties": { + "input_sha256": { + "pattern": "^[a-f0-9]{64}$", + "title": "Input Sha256", + "type": "string" + }, + "operation_id": { + "maxLength": 128, + "minLength": 1, + "pattern": "^[a-zA-Z0-9][a-zA-Z0-9._:-]*$", + "title": "Operation Id", + "type": "string" + }, + "schema_version": { + "const": 1, + "default": 1, + "title": "Schema Version", + "type": "integer" + }, + "status": { + "enum": [ + "pending", + "uncertain", + "complete", + "failed" + ], + "title": "Status", + "type": "string" + }, + "tool_name": { + "maxLength": 128, + "minLength": 1, + "pattern": "^[a-zA-Z0-9][a-zA-Z0-9._:-]*$", + "title": "Tool Name", + "type": "string" + } + }, + "required": [ + "operation_id", + "tool_name", + "input_sha256", + "status" + ], + "title": "RunOperationReference", + "type": "object" + }, + "RunProposalReference": { + "additionalProperties": false, + "properties": { + "proposal_id": { + "maxLength": 128, + "minLength": 1, + "pattern": "^[a-zA-Z0-9][a-zA-Z0-9._:-]*$", + "title": "Proposal Id", + "type": "string" + }, + "receipt": { + "anyOf": [ + { + "$ref": "#/$defs/ProposalReceipt" + }, + { + "type": "null" + } + ], + "default": null + }, + "revision": { + "minimum": 1, + "title": "Revision", + "type": "integer" + }, + "schema_version": { + "const": 1, + "default": 1, + "title": "Schema Version", + "type": "integer" + }, + "status": { + "enum": [ + "staged", + "evaluated", + "eligible", + "needs-review", + "invalid", + "conflict", + "publishing", + "accepted", + "rejected", + "withdrawn", + "uncertain" + ], + "title": "Status", + "type": "string" + } + }, + "required": [ + "proposal_id", + "revision", + "status" + ], + "title": "RunProposalReference", + "type": "object" + }, + "RunSourceReference": { + "additionalProperties": false, + "properties": { + "artifact_sha256": { + "anyOf": [ + { + "pattern": "^[a-f0-9]{64}$", + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Artifact Sha256" + }, + "extraction_id": { + "anyOf": [ + { + "pattern": "^[a-f0-9]{64}$", + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Extraction Id" + }, + "schema_version": { + "const": 1, + "default": 1, + "title": "Schema Version", + "type": "integer" + }, + "source_revision_id": { + "maxLength": 128, + "minLength": 1, + "pattern": "^[a-zA-Z0-9][a-zA-Z0-9._:-]*$", + "title": "Source Revision Id", + "type": "string" + }, + "status": { + "enum": [ + "selected", + "captured", + "extracted", + "unavailable", + "failed" + ], + "title": "Status", + "type": "string" + } + }, + "required": [ + "source_revision_id", + "status" + ], + "title": "RunSourceReference", + "type": "object" + }, + "RunState": { + "enum": [ + "active", + "completed", + "blocked" + ], + "title": "RunState", + "type": "string" + } + }, + "additionalProperties": false, + "properties": { + "budgets": { + "maxProperties": 100, + "patternProperties": { + "^[a-zA-Z0-9][a-zA-Z0-9._:-]*$": { + "minimum": 0, + "type": "integer" + } + }, + "propertyNames": { + "maxLength": 128, + "minLength": 1 + }, + "title": "Budgets", + "type": "object" + }, + "coverage": { + "$ref": "#/$defs/RunCoverage" + }, + "expected_revision": { + "maximum": 2147483646, + "minimum": 0, + "title": "Expected Revision", + "type": "integer" + }, + "output_hashes": { + "default": [], + "items": { + "pattern": "^[a-f0-9]{64}$", + "type": "string" + }, + "maxItems": 1000, + "title": "Output Hashes", + "type": "array" + }, + "pending_operations": { + "default": [], + "items": { + "$ref": "#/$defs/RunOperationReference" + }, + "maxItems": 1000, + "title": "Pending Operations", + "type": "array" + }, + "proposals": { + "default": [], + "items": { + "$ref": "#/$defs/RunProposalReference" + }, + "maxItems": 1000, + "title": "Proposals", + "type": "array" + }, + "run_id": { + "maxLength": 128, + "minLength": 1, + "pattern": "^[a-zA-Z0-9][a-zA-Z0-9._:-]*$", + "title": "Run Id", + "type": "string" + }, + "schema_version": { + "const": 1, + "default": 1, + "title": "Schema Version", + "type": "integer" + }, + "sources": { + "default": [], + "items": { + "$ref": "#/$defs/RunSourceReference" + }, + "maxItems": 500, + "title": "Sources", + "type": "array" + }, + "stage": { + "maxLength": 128, + "minLength": 1, + "pattern": "^[a-zA-Z0-9][a-zA-Z0-9._:-]*$", + "title": "Stage", + "type": "string" + }, + "state": { + "$ref": "#/$defs/RunState", + "default": "active" + } + }, + "required": [ + "run_id", + "expected_revision", + "stage" + ], + "title": "RunCheckpointRequest", + "type": "object" +} diff --git a/schema/v1/records/RunCreateRequest.schema.json b/schema/v1/records/RunCreateRequest.schema.json new file mode 100644 index 0000000..a9b3a5f --- /dev/null +++ b/schema/v1/records/RunCreateRequest.schema.json @@ -0,0 +1,168 @@ +{ + "$defs": { + "RunModulePin": { + "additionalProperties": false, + "properties": { + "implementation_version": { + "maxLength": 64, + "minLength": 1, + "title": "Implementation Version", + "type": "string" + }, + "module_id": { + "maxLength": 128, + "minLength": 1, + "pattern": "^[a-zA-Z0-9][a-zA-Z0-9._:-]*$", + "title": "Module Id", + "type": "string" + }, + "schema_version": { + "const": 1, + "default": 1, + "title": "Schema Version", + "type": "integer" + }, + "sha256": { + "pattern": "^[a-f0-9]{64}$", + "title": "Sha256", + "type": "string" + } + }, + "required": [ + "module_id", + "implementation_version", + "sha256" + ], + "title": "RunModulePin", + "type": "object" + }, + "Scope": { + "additionalProperties": false, + "description": "Project context plus optional project includes; all-project access is deliberate.", + "properties": { + "all_projects": { + "default": false, + "title": "All Projects", + "type": "boolean" + }, + "include_projects": { + "anyOf": [ + { + "items": { + "maxLength": 128, + "minLength": 1, + "pattern": "^[a-zA-Z0-9][a-zA-Z0-9._:-]*$", + "type": "string" + }, + "maxItems": 50, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Include Projects" + }, + "include_shared": { + "default": true, + "title": "Include Shared", + "type": "boolean" + }, + "projects": { + "items": { + "maxLength": 128, + "minLength": 1, + "pattern": "^[a-zA-Z0-9][a-zA-Z0-9._:-]*$", + "type": "string" + }, + "maxItems": 50, + "minItems": 1, + "title": "Projects", + "type": "array" + }, + "schema_version": { + "const": 1, + "default": 1, + "title": "Schema Version", + "type": "integer" + } + }, + "required": [ + "projects" + ], + "title": "Scope", + "type": "object" + } + }, + "additionalProperties": false, + "properties": { + "lens": { + "maxLength": 2000, + "minLength": 1, + "title": "Lens", + "type": "string" + }, + "module_pins": { + "items": { + "$ref": "#/$defs/RunModulePin" + }, + "maxItems": 100, + "minItems": 1, + "title": "Module Pins", + "type": "array" + }, + "objective": { + "maxLength": 4000, + "minLength": 1, + "title": "Objective", + "type": "string" + }, + "pack_id": { + "maxLength": 128, + "minLength": 1, + "pattern": "^[a-zA-Z0-9][a-zA-Z0-9._:-]*$", + "title": "Pack Id", + "type": "string" + }, + "pack_snapshot_digest": { + "pattern": "^[a-f0-9]{64}$", + "title": "Pack Snapshot Digest", + "type": "string" + }, + "pack_version": { + "maxLength": 64, + "minLength": 1, + "title": "Pack Version", + "type": "string" + }, + "run_id": { + "maxLength": 128, + "minLength": 1, + "pattern": "^[a-zA-Z0-9][a-zA-Z0-9._:-]*$", + "title": "Run Id", + "type": "string" + }, + "schema_version": { + "const": 1, + "default": 1, + "title": "Schema Version", + "type": "integer" + }, + "scope": { + "$ref": "#/$defs/Scope" + } + }, + "required": [ + "run_id", + "objective", + "scope", + "lens", + "pack_id", + "pack_version", + "pack_snapshot_digest", + "module_pins" + ], + "title": "RunCreateRequest", + "type": "object" +} diff --git a/schema/v1/records/RunRecord.schema.json b/schema/v1/records/RunRecord.schema.json new file mode 100644 index 0000000..a4f6189 --- /dev/null +++ b/schema/v1/records/RunRecord.schema.json @@ -0,0 +1,670 @@ +{ + "$defs": { + "Finding": { + "additionalProperties": false, + "properties": { + "code": { + "maxLength": 128, + "minLength": 1, + "pattern": "^[a-zA-Z0-9][a-zA-Z0-9._:-]*$", + "title": "Code", + "type": "string" + }, + "message": { + "maxLength": 1000, + "minLength": 1, + "title": "Message", + "type": "string" + }, + "path": { + "anyOf": [ + { + "maxLength": 256, + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Path" + }, + "schema_version": { + "const": 1, + "default": 1, + "title": "Schema Version", + "type": "integer" + } + }, + "required": [ + "code", + "message" + ], + "title": "Finding", + "type": "object" + }, + "ProposalReceipt": { + "additionalProperties": false, + "properties": { + "accepted_node_ids": { + "default": [], + "items": { + "maxLength": 128, + "minLength": 1, + "pattern": "^[a-zA-Z0-9][a-zA-Z0-9._:-]*$", + "type": "string" + }, + "title": "Accepted Node Ids", + "type": "array" + }, + "commit_id": { + "anyOf": [ + { + "pattern": "^(?:[a-f0-9]{40}|[a-f0-9]{64})$", + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Commit Id" + }, + "decided_at": { + "anyOf": [ + { + "format": "date-time", + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Decided At" + }, + "findings": { + "default": [], + "items": { + "$ref": "#/$defs/Finding" + }, + "title": "Findings", + "type": "array" + }, + "outcome": { + "enum": [ + "accepted", + "held", + "rejected", + "validation-failed" + ], + "title": "Outcome", + "type": "string" + }, + "payload_digest": { + "pattern": "^[a-f0-9]{64}$", + "title": "Payload Digest", + "type": "string" + }, + "proposal_id": { + "maxLength": 128, + "minLength": 1, + "pattern": "^[a-zA-Z0-9][a-zA-Z0-9._:-]*$", + "title": "Proposal Id", + "type": "string" + }, + "revision": { + "minimum": 1, + "title": "Revision", + "type": "integer" + }, + "schema_version": { + "const": 1, + "default": 1, + "title": "Schema Version", + "type": "integer" + }, + "state": { + "$ref": "#/$defs/ProposalState" + } + }, + "required": [ + "proposal_id", + "revision", + "payload_digest", + "state", + "outcome" + ], + "title": "ProposalReceipt", + "type": "object" + }, + "ProposalState": { + "enum": [ + "staged", + "validating", + "needs-review", + "accepted", + "rejected", + "withdrawn" + ], + "title": "ProposalState", + "type": "string" + }, + "RunCoverage": { + "additionalProperties": false, + "properties": { + "excluded": { + "default": [], + "items": { + "$ref": "#/$defs/RunCoverageItem" + }, + "maxItems": 10000, + "title": "Excluded", + "type": "array" + }, + "included": { + "default": [], + "items": { + "type": "string" + }, + "maxItems": 10000, + "title": "Included", + "type": "array" + }, + "schema_version": { + "const": 1, + "default": 1, + "title": "Schema Version", + "type": "integer" + }, + "selected": { + "default": [], + "items": { + "type": "string" + }, + "maxItems": 10000, + "title": "Selected", + "type": "array" + }, + "skipped": { + "default": [], + "items": { + "$ref": "#/$defs/RunCoverageItem" + }, + "maxItems": 10000, + "title": "Skipped", + "type": "array" + } + }, + "title": "RunCoverage", + "type": "object" + }, + "RunCoverageItem": { + "additionalProperties": false, + "properties": { + "item": { + "maxLength": 500, + "minLength": 1, + "title": "Item", + "type": "string" + }, + "reason": { + "maxLength": 1000, + "minLength": 1, + "title": "Reason", + "type": "string" + }, + "schema_version": { + "const": 1, + "default": 1, + "title": "Schema Version", + "type": "integer" + } + }, + "required": [ + "item", + "reason" + ], + "title": "RunCoverageItem", + "type": "object" + }, + "RunModulePin": { + "additionalProperties": false, + "properties": { + "implementation_version": { + "maxLength": 64, + "minLength": 1, + "title": "Implementation Version", + "type": "string" + }, + "module_id": { + "maxLength": 128, + "minLength": 1, + "pattern": "^[a-zA-Z0-9][a-zA-Z0-9._:-]*$", + "title": "Module Id", + "type": "string" + }, + "schema_version": { + "const": 1, + "default": 1, + "title": "Schema Version", + "type": "integer" + }, + "sha256": { + "pattern": "^[a-f0-9]{64}$", + "title": "Sha256", + "type": "string" + } + }, + "required": [ + "module_id", + "implementation_version", + "sha256" + ], + "title": "RunModulePin", + "type": "object" + }, + "RunOperationReference": { + "additionalProperties": false, + "properties": { + "input_sha256": { + "pattern": "^[a-f0-9]{64}$", + "title": "Input Sha256", + "type": "string" + }, + "operation_id": { + "maxLength": 128, + "minLength": 1, + "pattern": "^[a-zA-Z0-9][a-zA-Z0-9._:-]*$", + "title": "Operation Id", + "type": "string" + }, + "schema_version": { + "const": 1, + "default": 1, + "title": "Schema Version", + "type": "integer" + }, + "status": { + "enum": [ + "pending", + "uncertain", + "complete", + "failed" + ], + "title": "Status", + "type": "string" + }, + "tool_name": { + "maxLength": 128, + "minLength": 1, + "pattern": "^[a-zA-Z0-9][a-zA-Z0-9._:-]*$", + "title": "Tool Name", + "type": "string" + } + }, + "required": [ + "operation_id", + "tool_name", + "input_sha256", + "status" + ], + "title": "RunOperationReference", + "type": "object" + }, + "RunProposalReference": { + "additionalProperties": false, + "properties": { + "proposal_id": { + "maxLength": 128, + "minLength": 1, + "pattern": "^[a-zA-Z0-9][a-zA-Z0-9._:-]*$", + "title": "Proposal Id", + "type": "string" + }, + "receipt": { + "anyOf": [ + { + "$ref": "#/$defs/ProposalReceipt" + }, + { + "type": "null" + } + ], + "default": null + }, + "revision": { + "minimum": 1, + "title": "Revision", + "type": "integer" + }, + "schema_version": { + "const": 1, + "default": 1, + "title": "Schema Version", + "type": "integer" + }, + "status": { + "enum": [ + "staged", + "evaluated", + "eligible", + "needs-review", + "invalid", + "conflict", + "publishing", + "accepted", + "rejected", + "withdrawn", + "uncertain" + ], + "title": "Status", + "type": "string" + } + }, + "required": [ + "proposal_id", + "revision", + "status" + ], + "title": "RunProposalReference", + "type": "object" + }, + "RunSourceReference": { + "additionalProperties": false, + "properties": { + "artifact_sha256": { + "anyOf": [ + { + "pattern": "^[a-f0-9]{64}$", + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Artifact Sha256" + }, + "extraction_id": { + "anyOf": [ + { + "pattern": "^[a-f0-9]{64}$", + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Extraction Id" + }, + "schema_version": { + "const": 1, + "default": 1, + "title": "Schema Version", + "type": "integer" + }, + "source_revision_id": { + "maxLength": 128, + "minLength": 1, + "pattern": "^[a-zA-Z0-9][a-zA-Z0-9._:-]*$", + "title": "Source Revision Id", + "type": "string" + }, + "status": { + "enum": [ + "selected", + "captured", + "extracted", + "unavailable", + "failed" + ], + "title": "Status", + "type": "string" + } + }, + "required": [ + "source_revision_id", + "status" + ], + "title": "RunSourceReference", + "type": "object" + }, + "RunState": { + "enum": [ + "active", + "completed", + "blocked" + ], + "title": "RunState", + "type": "string" + }, + "Scope": { + "additionalProperties": false, + "description": "Project context plus optional project includes; all-project access is deliberate.", + "properties": { + "all_projects": { + "default": false, + "title": "All Projects", + "type": "boolean" + }, + "include_projects": { + "anyOf": [ + { + "items": { + "maxLength": 128, + "minLength": 1, + "pattern": "^[a-zA-Z0-9][a-zA-Z0-9._:-]*$", + "type": "string" + }, + "maxItems": 50, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Include Projects" + }, + "include_shared": { + "default": true, + "title": "Include Shared", + "type": "boolean" + }, + "projects": { + "items": { + "maxLength": 128, + "minLength": 1, + "pattern": "^[a-zA-Z0-9][a-zA-Z0-9._:-]*$", + "type": "string" + }, + "maxItems": 50, + "minItems": 1, + "title": "Projects", + "type": "array" + }, + "schema_version": { + "const": 1, + "default": 1, + "title": "Schema Version", + "type": "integer" + } + }, + "required": [ + "projects" + ], + "title": "Scope", + "type": "object" + } + }, + "additionalProperties": false, + "properties": { + "budgets": { + "maxProperties": 100, + "patternProperties": { + "^[a-zA-Z0-9][a-zA-Z0-9._:-]*$": { + "minimum": 0, + "type": "integer" + } + }, + "propertyNames": { + "maxLength": 128, + "minLength": 1 + }, + "title": "Budgets", + "type": "object" + }, + "checkpoint_digest": { + "anyOf": [ + { + "pattern": "^[a-f0-9]{64}$", + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Checkpoint Digest" + }, + "coverage": { + "$ref": "#/$defs/RunCoverage" + }, + "created_at": { + "format": "date-time", + "title": "Created At", + "type": "string" + }, + "lens": { + "maxLength": 2000, + "minLength": 1, + "title": "Lens", + "type": "string" + }, + "module_pins": { + "items": { + "$ref": "#/$defs/RunModulePin" + }, + "maxItems": 100, + "minItems": 1, + "title": "Module Pins", + "type": "array" + }, + "objective": { + "maxLength": 4000, + "minLength": 1, + "title": "Objective", + "type": "string" + }, + "output_hashes": { + "default": [], + "items": { + "pattern": "^[a-f0-9]{64}$", + "type": "string" + }, + "maxItems": 1000, + "title": "Output Hashes", + "type": "array" + }, + "pack_id": { + "maxLength": 128, + "minLength": 1, + "pattern": "^[a-zA-Z0-9][a-zA-Z0-9._:-]*$", + "title": "Pack Id", + "type": "string" + }, + "pack_snapshot_digest": { + "pattern": "^[a-f0-9]{64}$", + "title": "Pack Snapshot Digest", + "type": "string" + }, + "pack_version": { + "maxLength": 64, + "minLength": 1, + "title": "Pack Version", + "type": "string" + }, + "pending_operations": { + "default": [], + "items": { + "$ref": "#/$defs/RunOperationReference" + }, + "maxItems": 1000, + "title": "Pending Operations", + "type": "array" + }, + "proposals": { + "default": [], + "items": { + "$ref": "#/$defs/RunProposalReference" + }, + "maxItems": 1000, + "title": "Proposals", + "type": "array" + }, + "revision": { + "minimum": 0, + "title": "Revision", + "type": "integer" + }, + "run_id": { + "maxLength": 128, + "minLength": 1, + "pattern": "^[a-zA-Z0-9][a-zA-Z0-9._:-]*$", + "title": "Run Id", + "type": "string" + }, + "schema_version": { + "const": 1, + "default": 1, + "title": "Schema Version", + "type": "integer" + }, + "scope": { + "$ref": "#/$defs/Scope" + }, + "sources": { + "default": [], + "items": { + "$ref": "#/$defs/RunSourceReference" + }, + "maxItems": 500, + "title": "Sources", + "type": "array" + }, + "stage": { + "maxLength": 128, + "minLength": 1, + "pattern": "^[a-zA-Z0-9][a-zA-Z0-9._:-]*$", + "title": "Stage", + "type": "string" + }, + "state": { + "$ref": "#/$defs/RunState" + }, + "updated_at": { + "format": "date-time", + "title": "Updated At", + "type": "string" + } + }, + "required": [ + "run_id", + "revision", + "state", + "objective", + "scope", + "lens", + "pack_id", + "pack_version", + "pack_snapshot_digest", + "module_pins", + "stage", + "created_at", + "updated_at" + ], + "title": "RunRecord", + "type": "object" +} diff --git a/schema/v1/records/Scope.schema.json b/schema/v1/records/Scope.schema.json new file mode 100644 index 0000000..b0485c1 --- /dev/null +++ b/schema/v1/records/Scope.schema.json @@ -0,0 +1,58 @@ +{ + "additionalProperties": false, + "description": "Project context plus optional project includes; all-project access is deliberate.", + "properties": { + "all_projects": { + "default": false, + "title": "All Projects", + "type": "boolean" + }, + "include_projects": { + "anyOf": [ + { + "items": { + "maxLength": 128, + "minLength": 1, + "pattern": "^[a-zA-Z0-9][a-zA-Z0-9._:-]*$", + "type": "string" + }, + "maxItems": 50, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Include Projects" + }, + "include_shared": { + "default": true, + "title": "Include Shared", + "type": "boolean" + }, + "projects": { + "items": { + "maxLength": 128, + "minLength": 1, + "pattern": "^[a-zA-Z0-9][a-zA-Z0-9._:-]*$", + "type": "string" + }, + "maxItems": 50, + "minItems": 1, + "title": "Projects", + "type": "array" + }, + "schema_version": { + "const": 1, + "default": 1, + "title": "Schema Version", + "type": "integer" + } + }, + "required": [ + "projects" + ], + "title": "Scope", + "type": "object" +} diff --git a/schema/v1/records/SearchRequest.schema.json b/schema/v1/records/SearchRequest.schema.json new file mode 100644 index 0000000..375a96c --- /dev/null +++ b/schema/v1/records/SearchRequest.schema.json @@ -0,0 +1,229 @@ +{ + "$defs": { + "Confidence": { + "enum": [ + "low", + "medium", + "high" + ], + "title": "Confidence", + "type": "string" + }, + "NodeType": { + "enum": [ + "finding", + "problem", + "technique", + "case", + "entity", + "event", + "concept", + "datapoint", + "synthesis" + ], + "title": "NodeType", + "type": "string" + }, + "Scope": { + "additionalProperties": false, + "description": "Project context plus optional project includes; all-project access is deliberate.", + "properties": { + "all_projects": { + "default": false, + "title": "All Projects", + "type": "boolean" + }, + "include_projects": { + "anyOf": [ + { + "items": { + "maxLength": 128, + "minLength": 1, + "pattern": "^[a-zA-Z0-9][a-zA-Z0-9._:-]*$", + "type": "string" + }, + "maxItems": 50, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Include Projects" + }, + "include_shared": { + "default": true, + "title": "Include Shared", + "type": "boolean" + }, + "projects": { + "items": { + "maxLength": 128, + "minLength": 1, + "pattern": "^[a-zA-Z0-9][a-zA-Z0-9._:-]*$", + "type": "string" + }, + "maxItems": 50, + "minItems": 1, + "title": "Projects", + "type": "array" + }, + "schema_version": { + "const": 1, + "default": 1, + "title": "Schema Version", + "type": "integer" + } + }, + "required": [ + "projects" + ], + "title": "Scope", + "type": "object" + } + }, + "additionalProperties": false, + "properties": { + "as_of_from": { + "anyOf": [ + { + "format": "date", + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "As Of From" + }, + "as_of_to": { + "anyOf": [ + { + "format": "date", + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "As Of To" + }, + "audience": { + "default": [], + "items": { + "maxLength": 100, + "minLength": 1, + "type": "string" + }, + "maxItems": 20, + "title": "Audience", + "type": "array" + }, + "budget_tokens": { + "maximum": 50000, + "minimum": 256, + "title": "Budget Tokens", + "type": "integer" + }, + "confidences": { + "default": [], + "items": { + "$ref": "#/$defs/Confidence" + }, + "maxItems": 3, + "title": "Confidences", + "type": "array" + }, + "cursor": { + "anyOf": [ + { + "maxLength": 512, + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Cursor" + }, + "domain": { + "default": [], + "items": { + "maxLength": 100, + "minLength": 1, + "type": "string" + }, + "maxItems": 20, + "title": "Domain", + "type": "array" + }, + "limit": { + "default": 20, + "maximum": 50, + "minimum": 1, + "title": "Limit", + "type": "integer" + }, + "node_types": { + "default": [], + "items": { + "$ref": "#/$defs/NodeType" + }, + "maxItems": 9, + "title": "Node Types", + "type": "array" + }, + "query": { + "default": "", + "maxLength": 1000, + "title": "Query", + "type": "string" + }, + "schema_version": { + "const": 1, + "default": 1, + "title": "Schema Version", + "type": "integer" + }, + "scope": { + "$ref": "#/$defs/Scope" + }, + "stale_only": { + "default": false, + "title": "Stale Only", + "type": "boolean" + }, + "tags": { + "default": [], + "items": { + "maxLength": 100, + "minLength": 1, + "type": "string" + }, + "maxItems": 20, + "title": "Tags", + "type": "array" + }, + "use_for": { + "default": [], + "items": { + "maxLength": 100, + "minLength": 1, + "type": "string" + }, + "maxItems": 20, + "title": "Use For", + "type": "array" + } + }, + "required": [ + "scope", + "budget_tokens" + ], + "title": "SearchRequest", + "type": "object" +} diff --git a/schema/v1/records/ServiceDiagnostics.schema.json b/schema/v1/records/ServiceDiagnostics.schema.json new file mode 100644 index 0000000..521b756 --- /dev/null +++ b/schema/v1/records/ServiceDiagnostics.schema.json @@ -0,0 +1,244 @@ +{ + "$defs": { + "Scope": { + "additionalProperties": false, + "description": "Project context plus optional project includes; all-project access is deliberate.", + "properties": { + "all_projects": { + "default": false, + "title": "All Projects", + "type": "boolean" + }, + "include_projects": { + "anyOf": [ + { + "items": { + "maxLength": 128, + "minLength": 1, + "pattern": "^[a-zA-Z0-9][a-zA-Z0-9._:-]*$", + "type": "string" + }, + "maxItems": 50, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Include Projects" + }, + "include_shared": { + "default": true, + "title": "Include Shared", + "type": "boolean" + }, + "projects": { + "items": { + "maxLength": 128, + "minLength": 1, + "pattern": "^[a-zA-Z0-9][a-zA-Z0-9._:-]*$", + "type": "string" + }, + "maxItems": 50, + "minItems": 1, + "title": "Projects", + "type": "array" + }, + "schema_version": { + "const": 1, + "default": 1, + "title": "Schema Version", + "type": "integer" + } + }, + "required": [ + "projects" + ], + "title": "Scope", + "type": "object" + } + }, + "additionalProperties": false, + "properties": { + "api_version": { + "const": "v1", + "default": "v1", + "title": "Api Version", + "type": "string" + }, + "authentication": { + "enum": [ + "bearer", + "browser_session" + ], + "title": "Authentication", + "type": "string" + }, + "authorized_operations": { + "default": [], + "items": { + "maxLength": 128, + "minLength": 1, + "pattern": "^[a-zA-Z0-9][a-zA-Z0-9._:-]*$", + "type": "string" + }, + "title": "Authorized Operations", + "type": "array" + }, + "authorized_projects": { + "default": [], + "items": { + "maxLength": 128, + "minLength": 1, + "pattern": "^[a-zA-Z0-9][a-zA-Z0-9._:-]*$", + "type": "string" + }, + "title": "Authorized Projects", + "type": "array" + }, + "canonical_revision": { + "pattern": "^(?:[a-f0-9]{40}|[a-f0-9]{64})$", + "title": "Canonical Revision", + "type": "string" + }, + "capabilities": { + "default": [], + "items": { + "maxLength": 128, + "minLength": 1, + "pattern": "^[a-zA-Z0-9][a-zA-Z0-9._:-]*$", + "type": "string" + }, + "title": "Capabilities", + "type": "array" + }, + "catalog_sha256": { + "pattern": "^[a-f0-9]{64}$", + "title": "Catalog Sha256", + "type": "string" + }, + "connection_status": { + "const": "authenticated", + "default": "authenticated", + "title": "Connection Status", + "type": "string" + }, + "index_revision": { + "anyOf": [ + { + "pattern": "^(?:[a-f0-9]{40}|[a-f0-9]{64})$", + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Index Revision" + }, + "index_schema_version": { + "anyOf": [ + { + "minimum": 1, + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Index Schema Version" + }, + "index_state": { + "enum": [ + "current", + "lagging", + "not_built", + "not_configured", + "unavailable" + ], + "title": "Index State", + "type": "string" + }, + "max_upload_bytes": { + "minimum": 1, + "title": "Max Upload Bytes", + "type": "integer" + }, + "roles": { + "default": [], + "items": { + "maxLength": 128, + "minLength": 1, + "pattern": "^[a-zA-Z0-9][a-zA-Z0-9._:-]*$", + "type": "string" + }, + "title": "Roles", + "type": "array" + }, + "schema_version": { + "const": 1, + "default": 1, + "title": "Schema Version", + "type": "integer" + }, + "selected_scope": { + "anyOf": [ + { + "$ref": "#/$defs/Scope" + }, + { + "type": "null" + } + ], + "default": null + }, + "service_version": { + "maxLength": 64, + "minLength": 1, + "title": "Service Version", + "type": "string" + }, + "supported_media_types": { + "default": [], + "items": { + "type": "string" + }, + "maxItems": 20, + "title": "Supported Media Types", + "type": "array" + }, + "transport": { + "enum": [ + "http", + "mcp" + ], + "title": "Transport", + "type": "string" + }, + "upload_chunk_bytes": { + "minimum": 1, + "title": "Upload Chunk Bytes", + "type": "integer" + }, + "workflow_status": { + "const": "not_verified", + "default": "not_verified", + "title": "Workflow Status", + "type": "string" + } + }, + "required": [ + "service_version", + "catalog_sha256", + "transport", + "authentication", + "max_upload_bytes", + "upload_chunk_bytes", + "canonical_revision", + "index_state" + ], + "title": "ServiceDiagnostics", + "type": "object" +} diff --git a/schema/v1/records/SourceRevisionRecord.schema.json b/schema/v1/records/SourceRevisionRecord.schema.json new file mode 100644 index 0000000..83f461c --- /dev/null +++ b/schema/v1/records/SourceRevisionRecord.schema.json @@ -0,0 +1,204 @@ +{ + "$defs": { + "ReliabilityGrade": { + "enum": [ + "low", + "medium", + "high" + ], + "title": "ReliabilityGrade", + "type": "string" + } + }, + "additionalProperties": false, + "properties": { + "archive_status": { + "enum": [ + "archived", + "reference-only", + "capture-failed" + ], + "title": "Archive Status", + "type": "string" + }, + "artifact_sha256": { + "anyOf": [ + { + "pattern": "^[a-f0-9]{64}$", + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Artifact Sha256" + }, + "authors": { + "default": [], + "items": { + "type": "string" + }, + "maxItems": 50, + "title": "Authors", + "type": "array" + }, + "byte_length": { + "minimum": 0, + "title": "Byte Length", + "type": "integer" + }, + "capture_method": { + "default": "unspecified", + "enum": [ + "unspecified", + "local-file", + "url-fetch", + "pasted-text", + "notebooklm-export", + "transcript" + ], + "title": "Capture Method", + "type": "string" + }, + "captured_at": { + "format": "date-time", + "title": "Captured At", + "type": "string" + }, + "final_url": { + "anyOf": [ + { + "maxLength": 4000, + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Final Url" + }, + "ingested_for": { + "items": { + "maxLength": 128, + "minLength": 1, + "pattern": "^[a-zA-Z0-9][a-zA-Z0-9._:-]*$", + "type": "string" + }, + "maxItems": 50, + "minItems": 1, + "title": "Ingested For", + "type": "array" + }, + "license": { + "maxLength": 500, + "minLength": 1, + "title": "License", + "type": "string" + }, + "media_type": { + "maxLength": 128, + "minLength": 1, + "title": "Media Type", + "type": "string" + }, + "original_url": { + "anyOf": [ + { + "maxLength": 4000, + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Original Url" + }, + "publication_date": { + "anyOf": [ + { + "format": "date-time", + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Publication Date" + }, + "publisher": { + "anyOf": [ + { + "maxLength": 300, + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Publisher" + }, + "reliability_grade": { + "$ref": "#/$defs/ReliabilityGrade" + }, + "revision_id": { + "maxLength": 128, + "minLength": 1, + "pattern": "^[a-zA-Z0-9][a-zA-Z0-9._:-]*$", + "title": "Revision Id", + "type": "string" + }, + "schema_version": { + "const": 1, + "default": 1, + "title": "Schema Version", + "type": "integer" + }, + "source_id": { + "maxLength": 128, + "minLength": 1, + "pattern": "^[a-zA-Z0-9][a-zA-Z0-9._:-]*$", + "title": "Source Id", + "type": "string" + }, + "source_type": { + "enum": [ + "paper", + "review", + "press", + "vendor", + "internal", + "talk", + "export", + "other" + ], + "title": "Source Type", + "type": "string" + }, + "title": { + "maxLength": 500, + "minLength": 1, + "title": "Title", + "type": "string" + } + }, + "required": [ + "source_id", + "revision_id", + "media_type", + "byte_length", + "captured_at", + "title", + "license", + "source_type", + "reliability_grade", + "ingested_for", + "archive_status" + ], + "title": "SourceRevisionRecord", + "type": "object" +} diff --git a/schema/v1/records/TypedEdge.schema.json b/schema/v1/records/TypedEdge.schema.json new file mode 100644 index 0000000..7547cde --- /dev/null +++ b/schema/v1/records/TypedEdge.schema.json @@ -0,0 +1,262 @@ +{ + "$defs": { + "EdgeEndpoint": { + "additionalProperties": false, + "properties": { + "id": { + "maxLength": 128, + "minLength": 1, + "pattern": "^[a-zA-Z0-9][a-zA-Z0-9._:-]*$", + "title": "Id", + "type": "string" + }, + "kind": { + "enum": [ + "node", + "source", + "project" + ], + "title": "Kind", + "type": "string" + }, + "schema_version": { + "const": 1, + "default": 1, + "title": "Schema Version", + "type": "integer" + } + }, + "required": [ + "kind", + "id" + ], + "title": "EdgeEndpoint", + "type": "object" + }, + "EvidenceReference": { + "additionalProperties": false, + "properties": { + "assertion_digest": { + "pattern": "^[a-f0-9]{64}$", + "title": "Assertion Digest", + "type": "string" + }, + "assertion_path": { + "maxLength": 256, + "minLength": 1, + "title": "Assertion Path", + "type": "string" + }, + "extraction_id": { + "pattern": "^[a-f0-9]{64}$", + "title": "Extraction Id", + "type": "string" + }, + "locator": { + "$ref": "#/$defs/Locator" + }, + "normalization_profile": { + "const": "unicode-nfc-lf-v1", + "title": "Normalization Profile", + "type": "string" + }, + "quote": { + "maxLength": 8000, + "minLength": 1, + "title": "Quote", + "type": "string" + }, + "schema_version": { + "const": 1, + "default": 1, + "title": "Schema Version", + "type": "integer" + }, + "source_revision_id": { + "maxLength": 128, + "minLength": 1, + "pattern": "^[a-zA-Z0-9][a-zA-Z0-9._:-]*$", + "title": "Source Revision Id", + "type": "string" + } + }, + "required": [ + "source_revision_id", + "extraction_id", + "assertion_path", + "assertion_digest", + "quote", + "locator", + "normalization_profile" + ], + "title": "EvidenceReference", + "type": "object" + }, + "Locator": { + "additionalProperties": false, + "properties": { + "block_id": { + "maxLength": 128, + "minLength": 1, + "pattern": "^[a-zA-Z0-9][a-zA-Z0-9._:-]*$", + "title": "Block Id", + "type": "string" + }, + "end": { + "exclusiveMinimum": 0, + "title": "End", + "type": "integer" + }, + "kind": { + "enum": [ + "text_span", + "pdf_page_span", + "transcript_timestamp_span" + ], + "title": "Kind", + "type": "string" + }, + "page": { + "anyOf": [ + { + "minimum": 1, + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Page" + }, + "schema_version": { + "const": 1, + "default": 1, + "title": "Schema Version", + "type": "integer" + }, + "start": { + "minimum": 0, + "title": "Start", + "type": "integer" + }, + "time_end_ms": { + "anyOf": [ + { + "minimum": 0, + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Time End Ms" + }, + "time_start_ms": { + "anyOf": [ + { + "minimum": 0, + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Time Start Ms" + } + }, + "required": [ + "kind", + "block_id", + "start", + "end" + ], + "title": "Locator", + "type": "object" + } + }, + "additionalProperties": false, + "properties": { + "conditions": { + "anyOf": [ + { + "maxLength": 2000, + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Conditions" + }, + "edge_id": { + "maxLength": 128, + "minLength": 1, + "pattern": "^[a-zA-Z0-9][a-zA-Z0-9._:-]*$", + "title": "Edge Id", + "type": "string" + }, + "edge_type": { + "enum": [ + "solves", + "addresses", + "causes", + "supports", + "contradicts", + "instance_of", + "part_of", + "precedes", + "supersedes", + "compared_with", + "derived_from", + "applies_to" + ], + "title": "Edge Type", + "type": "string" + }, + "evidence": { + "default": [], + "items": { + "$ref": "#/$defs/EvidenceReference" + }, + "title": "Evidence", + "type": "array" + }, + "interpretation_rationale": { + "anyOf": [ + { + "maxLength": 4000, + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Interpretation Rationale" + }, + "schema_version": { + "const": 1, + "default": 1, + "title": "Schema Version", + "type": "integer" + }, + "source": { + "$ref": "#/$defs/EdgeEndpoint" + }, + "target": { + "$ref": "#/$defs/EdgeEndpoint" + } + }, + "required": [ + "edge_id", + "edge_type", + "source", + "target" + ], + "title": "TypedEdge", + "type": "object" +} diff --git a/scripts/benchmark_search.py b/scripts/benchmark_search.py new file mode 100644 index 0000000..12b50f1 --- /dev/null +++ b/scripts/benchmark_search.py @@ -0,0 +1,243 @@ +"""Measure the SQLite search index on a deterministic synthetic corpus. + +This is an adapter benchmark, not the PRD's end-to-end or semantic-quality gate. +Run with ``./scripts/python-tool python scripts/benchmark_search.py``. +""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import math +import os +import platform +import sqlite3 +import statistics +import tempfile +import time +from collections.abc import Sequence +from datetime import UTC, datetime +from pathlib import Path + +from gewu.adapters.sqlite_search import SQLiteSearchIndex +from gewu.domain.contracts import ( + Claim, + Confidence, + EvidenceReference, + KnowledgeNode, + Lifecycle, + Locator, + NodeType, + ReviewState, + SupportAssessment, + SupportStatus, +) +from gewu.domain.validation import assertion_digest, evidence_digest + + +def base_node() -> KnowledgeNode: + text = "Interactive experiences welcomed visitors during the synthetic benchmark." + assertion = assertion_digest(text) + evidence = EvidenceReference( + source_revision_id="benchmark:source:r1", + extraction_id="a" * 64, + assertion_path="claims[0].text", + assertion_digest=assertion, + quote=text, + locator=Locator(kind="text_span", block_id="benchmark-block", start=0, end=len(text)), + normalization_profile="unicode-nfc-lf-v1", + ) + now = datetime.now(UTC) + claim = Claim( + claim_id="benchmark:claim", + text=text, + evidence_type="reported", + evidence=(evidence,), + assessment=SupportAssessment( + assessment_id="benchmark:assessment", + assertion_digest=assertion, + evidence_digests=(evidence_digest(evidence),), + assessor_id="benchmark:assessor", + run_id="benchmark:run", + method="synthetic benchmark fixture", + status=SupportStatus.SUPPORTED, + rationale="Fixture only; not a semantic assessment.", + ), + ) + return KnowledgeNode( + node_id="benchmark:template", + project_id="bench-project-000", + node_type=NodeType.FINDING, + title="Interactive visitor evidence template", + summary=text, + created=now, + updated=now, + review_state=ReviewState.REVIEWED, + lifecycle=Lifecycle.ACTIVE, + confidence=Confidence.MEDIUM, + claim_support="supported", + run_id="benchmark:run", + agent_id="benchmark:agent", + model_id="synthetic-fixture", + claims=(claim,), + ) + + +class SyntheticRepository: + """Small in-memory canonical port that feeds validated production records.""" + + def __init__(self, nodes_per_project: int, projects: int) -> None: + revision = hashlib.sha256(f"benchmark:{nodes_per_project}:{projects}".encode()).hexdigest() + self._revision = revision + self._records: dict[str, bytes] = {} + self.project_ids = tuple(f"bench-project-{index:03d}" for index in range(projects)) + template = base_node() + for project_index, project_id in enumerate(self.project_ids): + project_key = hashlib.sha256(project_id.encode()).hexdigest() + for node_index in range(nodes_per_project): + node_id = f"benchmark:{project_index:03d}:node-{node_index:06d}" + node_key = hashlib.sha256(node_id.encode()).hexdigest() + path = f"projects/{project_key}/nodes/{node_key}.yaml" + node = template.model_copy( + update={ + "node_id": node_id, + "project_id": project_id, + "title": f"Interactive visitor evidence {project_index} {node_index}", + "summary": ( + "Interactive experiences welcomed visitors; " + f"synthetic record {node_index}." + ), + } + ) + self._records[path] = node.model_dump_json().encode("utf-8") + + def head(self) -> str: + return self._revision + + def list_files(self, revision: str, prefix: str) -> tuple[str, ...]: + if revision != self._revision: + raise ValueError("Unknown synthetic revision") + return tuple(path for path in self._records if path.startswith(prefix)) + + def read_file(self, revision: str, path: str) -> bytes | None: + if revision != self._revision: + raise ValueError("Unknown synthetic revision") + return self._records.get(path) + + +def percentile(samples: Sequence[float], value: float) -> float: + ordered = sorted(samples) + position = max(0, math.ceil(len(ordered) * value) - 1) + return ordered[position] + + +def processor_name() -> str: + reported = platform.processor() + if reported: + return reported + if platform.system() == "Linux": + try: + for line in Path("/proc/cpuinfo").read_text(encoding="utf-8").splitlines(): + if line.startswith("model name"): + return line.partition(":")[2].strip() + except OSError: + pass + return "not reported" + + +def run(nodes_per_project: int, projects: int, samples: int, root: Path) -> dict[str, object]: + build_started = time.perf_counter() + repository = SyntheticRepository(nodes_per_project, projects) + seed_seconds = time.perf_counter() - build_started + index = SQLiteSearchIndex(root / "search.sqlite3", repository) # type: ignore[arg-type] + + cold_started = time.perf_counter() + cold_result = index.search( + query="interactive visitors", + project_ids=(repository.project_ids[0],), + include_shared=False, + limit=20, + ) + cold_seconds = time.perf_counter() - cold_started + + timings: list[float] = [] + for _ in range(samples): + started = time.perf_counter() + result = index.search( + query="interactive visitors", + project_ids=(repository.project_ids[0],), + include_shared=False, + limit=20, + ) + timings.append((time.perf_counter() - started) * 1000) + if len(result.nodes) != 20: + raise RuntimeError(f"Expected 20 results, received {len(result.nodes)}") + + total_nodes = nodes_per_project * projects + return { + "benchmark": "sqlite-search-adapter-synthetic-v1", + "evidence_scope": ( + "synthetic adapter benchmark; excludes HTTP/auth, Git object reads, " + "and semantic relevance" + ), + "environment": { + "platform": platform.platform(), + "processor": processor_name(), + "logical_cpus": os.cpu_count(), + "python": platform.python_version(), + "sqlite": sqlite3.sqlite_version, + }, + "workload": { + "projects": projects, + "nodes_per_project": nodes_per_project, + "total_nodes": total_nodes, + "query_project": repository.project_ids[0], + "query": "interactive visitors", + "page_size": 20, + "warm_samples": samples, + }, + "measurements": { + "synthetic_seed_seconds": round(seed_seconds, 3), + "cold_index_rebuild_and_first_search_seconds": round(cold_seconds, 3), + "warm_search_ms": { + "p50": round(statistics.median(timings), 3), + "p95": round(percentile(timings, 0.95), 3), + "max": round(max(timings), 3), + }, + "first_result_count": len(cold_result.nodes), + }, + "limitations": [ + "synthetic records reuse one valid claim/citation shape and are not a relevance corpus", + "in-memory canonical reads omit Git filesystem and HTTP/API overhead", + "the PRD hardware, cache state, concurrency, and percentile decisions are not fixed", + ], + } + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--nodes-per-project", type=int, default=10_000) + parser.add_argument("--projects", type=int, default=1) + parser.add_argument("--samples", type=int, default=50) + parser.add_argument("--output", type=Path) + args = parser.parse_args() + if min(args.nodes_per_project, args.projects, args.samples) < 1: + parser.error("node, project, and sample counts must be positive") + + with tempfile.TemporaryDirectory(prefix="gewu-search-benchmark-") as temporary: + report = run( + args.nodes_per_project, + args.projects, + args.samples, + Path(temporary), + ) + rendered = json.dumps(report, indent=2, sort_keys=True) + "\n" + if args.output: + args.output.parent.mkdir(parents=True, exist_ok=True) + args.output.write_text(rendered, encoding="utf-8") + print(rendered, end="") + + +if __name__ == "__main__": + main() diff --git a/scripts/check_boundaries.py b/scripts/check_boundaries.py new file mode 100644 index 0000000..704a38e --- /dev/null +++ b/scripts/check_boundaries.py @@ -0,0 +1,30 @@ +"""Enforce inward dependencies in the domain package.""" + +import ast +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +FORBIDDEN = ("fastapi", "mcp", "gewu.adapters", "gewu.bootstrap", "gewu.transports") + + +def main() -> None: + domain = ROOT / "src" / "gewu" / "domain" + violations: list[str] = [] + for path in domain.rglob("*.py"): + tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path)) + for node in ast.walk(tree): + if isinstance(node, ast.Import): + names = [alias.name for alias in node.names] + elif isinstance(node, ast.ImportFrom): + names = [node.module or ""] + else: + continue + for name in names: + if any(name == prefix or name.startswith(prefix + ".") for prefix in FORBIDDEN): + violations.append(f"{path.relative_to(ROOT)}:{node.lineno}: {name}") + if violations: + raise SystemExit("Forbidden domain dependencies:\n" + "\n".join(violations)) + + +if __name__ == "__main__": + main() diff --git a/scripts/export_agent_contract.py b/scripts/export_agent_contract.py new file mode 100644 index 0000000..fd84706 --- /dev/null +++ b/scripts/export_agent_contract.py @@ -0,0 +1,116 @@ +"""Bind the GEWU agent pack to the MCP tools actually exposed by the service.""" + +from __future__ import annotations + +import asyncio +import hashlib +import json +import shutil +import tempfile +from pathlib import Path +from typing import Any + +from gewu.adapters.git_repository import GitCanonicalRepository +from gewu.adapters.sqlite_identity import SQLiteIdentityStore +from gewu.adapters.sqlite_search import SQLiteSearchIndex +from gewu.adapters.text_extractor import TextMarkdownExtractor +from gewu.application.acceptance import AcceptanceService +from gewu.application.context import ValidationContext +from gewu.application.reading import CanonicalNodeReader, CanonicalSourceReader +from gewu.application.search import CanonicalSearchService +from gewu.application.uploads import LocalArtifactStore +from gewu.domain.operations import Transport, operations_for +from gewu.transports.mcp_server import create_mcp_server + +ROOT = Path(__file__).resolve().parents[1] +PACK = ROOT / "agents" / "gewu" +CONTRACT_PATH = PACK / "contracts" / "service-v1.json" +SCHEMA_OUTPUT = PACK / "contracts" / "schemas" +CATALOG_OUTPUT = PACK / "contracts" / "catalog-v1.json" + + +def _sha256(path: Path) -> str: + return hashlib.sha256(path.read_bytes()).hexdigest() + + +async def _tool_inventory(data_root: Path) -> list[dict[str, Any]]: + repository = GitCanonicalRepository(data_root / "knowledge.git") + identities = SQLiteIdentityStore(data_root / "identity.sqlite3") + artifact_store = LocalArtifactStore(data_root / "artifacts") + + def context_provider() -> ValidationContext: + return ValidationContext({}, {}, {}, {}, {}) + + acceptance_service = AcceptanceService( + repository=repository, + artifact_store=artifact_store, + workflow_root=data_root / "workflow", + context_provider=context_provider, + actor_is_current=lambda _actor: True, + ) + search_service = CanonicalSearchService( + index=SQLiteSearchIndex(data_root / "index.sqlite3", repository), + project_ids_provider=lambda: (), + canonical_head=repository.head, + ) + server = create_mcp_server( + identity_provider=identities, + node_reader=CanonicalNodeReader(repository), + source_reader=CanonicalSourceReader(repository), + search_service=search_service, + artifact_store=artifact_store, + extractor=TextMarkdownExtractor(), + acceptance_service=acceptance_service, + context_provider=context_provider, + ) + tools = await server.list_tools() + return [ + { + "name": tool.name, + "description": tool.description, + "input_schema": tool.input_schema, + } + for tool in tools + ] + + +def build_contract(data_root: Path, *, schema_output: Path | None = None) -> dict[str, Any]: + catalog_path = ROOT / "schema" / "v1" / "catalog.json" + catalog = json.loads(catalog_path.read_text(encoding="utf-8")) + tools = asyncio.run(_tool_inventory(data_root)) + expected_names = {item.name for item in operations_for(Transport.MCP)} + actual_names = {tool["name"] for tool in tools} + if actual_names != expected_names: + raise RuntimeError("MCP tool inventory does not match the explicit operation catalog") + if schema_output is not None: + schema_output.mkdir(parents=True, exist_ok=True) + record_schemas = [] + for source in sorted((ROOT / "schema" / "v1" / "records").glob("*.schema.json")): + destination = schema_output / source.name if schema_output is not None else source + if schema_output is not None: + shutil.copyfile(source, destination) + record_schemas.append({"path": f"schemas/{source.name}", "sha256": _sha256(destination)}) + return { + "contract_id": "gewu.service.v1", + "contract_version": 1, + "api_version": catalog["api_version"], + "catalog_sha256": _sha256(catalog_path), + "operations": [item for item in catalog["operations"] if "mcp" in item["transports"]], + "record_schemas": record_schemas, + "tools": tools, + } + + +def main() -> None: + CONTRACT_PATH.parent.mkdir(parents=True, exist_ok=True) + shutil.copyfile(ROOT / "schema" / "v1" / "catalog.json", CATALOG_OUTPUT) + with tempfile.TemporaryDirectory(prefix="gewu-agent-contract-") as temporary: + contract = build_contract(Path(temporary), schema_output=SCHEMA_OUTPUT) + CONTRACT_PATH.write_text( + json.dumps(contract, ensure_ascii=False, indent=2, sort_keys=True) + "\n", + encoding="utf-8", + ) + + +if __name__ == "__main__": + main() diff --git a/scripts/export_catalog.py b/scripts/export_catalog.py new file mode 100644 index 0000000..4c12e0b --- /dev/null +++ b/scripts/export_catalog.py @@ -0,0 +1,16 @@ +"""Export the application operation allowlist for generated adapters.""" + +from pathlib import Path + +from gewu.domain.operations import catalog_bytes + +ROOT = Path(__file__).resolve().parents[1] +DESTINATION = ROOT / "schema" / "v1" / "catalog.json" + + +def main() -> None: + DESTINATION.write_bytes(catalog_bytes()) + + +if __name__ == "__main__": + main() diff --git a/scripts/export_openapi.py b/scripts/export_openapi.py new file mode 100644 index 0000000..e8f79fd --- /dev/null +++ b/scripts/export_openapi.py @@ -0,0 +1,64 @@ +"""Export the composed HTTP API schema without touching the operator's data root.""" + +from __future__ import annotations + +import json +import os +import tempfile +from pathlib import Path +from typing import Any + +from gewu.bootstrap.app import create_local_app + +ROOT = Path(__file__).resolve().parents[1] +DESTINATION = ROOT / "schema" / "v1" / "openapi.json" + + +def componentize_record_schema( + name: str, record_schema: dict[str, Any], components: dict[str, Any] +) -> None: + definitions = record_schema.pop("$defs", {}) + + def rewrite_local_refs(value: Any) -> Any: + if isinstance(value, list): + return [rewrite_local_refs(item) for item in value] + if not isinstance(value, dict): + return value + rewritten = {key: rewrite_local_refs(item) for key, item in value.items() if key != "$defs"} + reference = rewritten.get("$ref") + if isinstance(reference, str) and reference.startswith("#/$defs/"): + definition_name = reference.removeprefix("#/$defs/") + rewritten["$ref"] = f"#/components/schemas/{name}__{definition_name}" + return rewritten + + for definition_name, definition in definitions.items(): + component_name = f"{name}__{definition_name}" + components.setdefault(component_name, rewrite_local_refs(definition)) + components.setdefault(name, rewrite_local_refs(record_schema)) + + +def main() -> None: + previous_data_root = os.environ.get("GEWU_DATA_ROOT") + with tempfile.TemporaryDirectory(prefix="gewu-openapi-") as temporary: + os.environ["GEWU_DATA_ROOT"] = str(Path(temporary) / "data") + try: + schema: dict[str, Any] = create_local_app().openapi() + finally: + if previous_data_root is None: + os.environ.pop("GEWU_DATA_ROOT", None) + else: + os.environ["GEWU_DATA_ROOT"] = previous_data_root + schema_components = schema.setdefault("components", {}).setdefault("schemas", {}) + for record_schema in sorted((ROOT / "schema" / "v1" / "records").glob("*.schema.json")): + component_name = record_schema.name.removesuffix(".schema.json") + schema_value = json.loads(record_schema.read_text(encoding="utf-8")) + componentize_record_schema(component_name, schema_value, schema_components) + DESTINATION.parent.mkdir(parents=True, exist_ok=True) + DESTINATION.write_text( + json.dumps(schema, ensure_ascii=False, indent=2, sort_keys=True) + "\n", + encoding="utf-8", + ) + + +if __name__ == "__main__": + main() diff --git a/scripts/export_schemas.py b/scripts/export_schemas.py new file mode 100644 index 0000000..112be8d --- /dev/null +++ b/scripts/export_schemas.py @@ -0,0 +1,64 @@ +"""Export public Pydantic records as versioned JSON Schemas.""" + +import json +from pathlib import Path + +from gewu.domain.contracts import ( + AcceptanceManifest, + AutoAcceptPolicy, + DomainError, + ExtractionRecord, + KnowledgeNode, + NeighborRequest, + ProjectRecord, + ProposalReceipt, + ProposalRevision, + ProposalStatus, + ReadEnvelope, + RunCheckpointRequest, + RunCreateRequest, + RunRecord, + Scope, + SearchRequest, + ServiceDiagnostics, + SourceRevisionRecord, + TypedEdge, +) + +ROOT = Path(__file__).resolve().parents[1] +DESTINATION = ROOT / "schema" / "v1" / "records" +MODELS = ( + AcceptanceManifest, + AutoAcceptPolicy, + DomainError, + ExtractionRecord, + KnowledgeNode, + NeighborRequest, + ProjectRecord, + ProposalReceipt, + ProposalRevision, + ProposalStatus, + ReadEnvelope, + ServiceDiagnostics, + RunCheckpointRequest, + RunCreateRequest, + RunRecord, + SearchRequest, + Scope, + SourceRevisionRecord, + TypedEdge, +) + + +def main() -> None: + DESTINATION.mkdir(parents=True, exist_ok=True) + for model in MODELS: + output = DESTINATION / f"{model.__name__}.schema.json" + output.write_text( + json.dumps(model.model_json_schema(mode="validation"), indent=2, sort_keys=True) + "\n", + encoding="utf-8", + ) + + +if __name__ == "__main__": + main() diff --git a/scripts/provision_credential.py b/scripts/provision_credential.py new file mode 100644 index 0000000..836abba --- /dev/null +++ b/scripts/provision_credential.py @@ -0,0 +1,34 @@ +"""Provision one local service credential; the bearer key is shown only once.""" + +from __future__ import annotations + +import argparse +import os +from pathlib import Path + +from gewu.adapters.sqlite_identity import SQLiteIdentityStore + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--credential-id", required=True) + parser.add_argument("--principal-id", required=True) + parser.add_argument( + "--role", action="append", choices=("admin", "editor", "viewer", "agent"), required=True + ) + parser.add_argument("--project", action="append", default=[]) + arguments = parser.parse_args() + data_root = Path(os.environ.get("GEWU_DATA_ROOT", "./.data/gewu")).expanduser().absolute() + data_root.mkdir(parents=True, exist_ok=True, mode=0o700) + token = SQLiteIdentityStore(data_root / "identity.sqlite3").provision( + credential_id=arguments.credential_id, + principal_id=arguments.principal_id, + roles=tuple(arguments.role), + project_ids=tuple(arguments.project), + ) + print("Store this bearer credential securely; it cannot be displayed again:") + print(token) + + +if __name__ == "__main__": + main() diff --git a/scripts/python-tool b/scripts/python-tool new file mode 100755 index 0000000..40c7335 --- /dev/null +++ b/scripts/python-tool @@ -0,0 +1,25 @@ +#!/usr/bin/env bash +set -euo pipefail + +if command -v uv >/dev/null 2>&1; then + exec uv run "$@" +fi + +python_bin=".venv/bin/python" +if [[ ! -x "$python_bin" ]]; then + python3 -m venv .venv +fi + +if ! "$python_bin" -m pip --version >/dev/null 2>&1; then + "$python_bin" -m ensurepip --upgrade +fi + +marker=".venv/.gewu-dev-installed" +if [[ ! -f "$marker" ]]; then + "$python_bin" -m pip install --disable-pip-version-check -e '.[dev]' + touch "$marker" +fi + +tool="$1" +shift +exec ".venv/bin/$tool" "$@" diff --git a/scripts/seed_test_registry.py b/scripts/seed_test_registry.py new file mode 100644 index 0000000..146fd9c --- /dev/null +++ b/scripts/seed_test_registry.py @@ -0,0 +1,74 @@ +"""Install the explicitly labeled two-project test registry for disposable work.""" + +from __future__ import annotations + +import argparse +import json +import os +import secrets +from pathlib import Path + +from gewu.application.paths import reject_symlink_components + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--replace", action="store_true") + arguments = parser.parse_args() + data_root = Path(os.environ.get("GEWU_DATA_ROOT", "./.data/gewu")).expanduser().absolute() + reject_symlink_components(data_root) + control_root = data_root / "control" + reject_symlink_components(control_root) + control_root.mkdir(parents=True, exist_ok=True, mode=0o700) + control_root.chmod(0o700) + destination = control_root / "registry.json" + if destination.exists() and not arguments.replace: + raise SystemExit("Registry exists; pass --replace only to deliberately reset it") + + fixture_path = ( + Path(__file__).resolve().parents[1] / "schema" / "fixtures" / "pilot-projects.json" + ) + + def reject_duplicate_keys(pairs: list[tuple[str, object]]) -> dict[str, object]: + result: dict[str, object] = {} + for key, value in pairs: + if key in result: + raise ValueError("Test fixture has duplicate JSON keys") + result[key] = value + return result + + fixture = json.loads( + fixture_path.read_text(encoding="utf-8"), object_pairs_hook=reject_duplicate_keys + ) + project_fields = { + "project_id", + "title", + "status", + "north_star", + "key_questions", + "autonomy_policy_id", + } + projects = [] + for item in fixture["projects"]: + project = {key: value for key, value in item.items() if key in project_fields} + projects.append(project) + config = { + "schema_version": 1, + "projects": projects, + "policies": fixture["test_policies"], + } + temporary = control_root / f".{secrets.token_hex(12)}.tmp" + try: + with temporary.open("x", encoding="utf-8") as stream: + json.dump(config, stream, ensure_ascii=False, allow_nan=False, indent=2) + stream.flush() + os.fsync(stream.fileno()) + os.chmod(temporary, 0o600) + os.replace(temporary, destination) + finally: + temporary.unlink(missing_ok=True) + print("Installed disposable fixture projects and test-only acceptance policies.") + + +if __name__ == "__main__": + main() diff --git a/src/gewu/__init__.py b/src/gewu/__init__.py new file mode 100644 index 0000000..54cccaa --- /dev/null +++ b/src/gewu/__init__.py @@ -0,0 +1 @@ +"""GEWU knowledge service.""" diff --git a/src/gewu/adapters/__init__.py b/src/gewu/adapters/__init__.py new file mode 100644 index 0000000..bbf0b81 --- /dev/null +++ b/src/gewu/adapters/__init__.py @@ -0,0 +1 @@ +"""Replaceable infrastructure adapters.""" diff --git a/src/gewu/adapters/embeddings.py b/src/gewu/adapters/embeddings.py new file mode 100644 index 0000000..5e38351 --- /dev/null +++ b/src/gewu/adapters/embeddings.py @@ -0,0 +1,125 @@ +"""Offline, CPU Sentence Transformers adapter for derived retrieval indexes.""" + +from __future__ import annotations + +import hashlib +import importlib +import math +from pathlib import Path +from typing import Any, Protocol + + +class EmbeddingProviderError(RuntimeError): + """Safe failure while loading a local model or encoding text.""" + + +class _Encoder(Protocol): + def encode( + self, + sentences: list[str], + *, + normalize_embeddings: bool, + convert_to_numpy: bool, + show_progress_bar: bool, + ) -> Any: ... + + +def model_artifact_sha256(path: Path) -> str: + """Hash a local model tree deterministically, including file names and bytes.""" + + root = path.resolve(strict=True) + if not root.is_dir(): + raise EmbeddingProviderError("Embedding model path must be a directory") + digest = hashlib.sha256() + files = sorted(item for item in root.rglob("*") if item.is_file()) + if not files: + raise EmbeddingProviderError("Embedding model directory is empty") + for item in files: + resolved = item.resolve(strict=True) + if not resolved.is_relative_to(root): + raise EmbeddingProviderError("Embedding model contains an external file") + relative = item.relative_to(root).as_posix().encode("utf-8") + digest.update(len(relative).to_bytes(4, "big")) + digest.update(relative) + with resolved.open("rb") as stream: + while chunk := stream.read(1024 * 1024): + digest.update(chunk) + return digest.hexdigest() + + +class LocalSentenceTransformer: + """Loads only a preinstalled model directory; never fetches weights at runtime.""" + + def __init__( + self, + model_path: Path, + *, + model_id: str, + expected_sha256: str, + dimensions: int = 384, + encoder: _Encoder | None = None, + ) -> None: + self._path = model_path.resolve(strict=True) + self._model_id = model_id + self._dimensions = dimensions + actual_sha256 = model_artifact_sha256(self._path) + if actual_sha256 != expected_sha256: + raise EmbeddingProviderError("Embedding model artifact digest does not match") + self._artifact_sha256 = actual_sha256 + if dimensions < 1: + raise EmbeddingProviderError("Embedding dimensions must be positive") + if encoder is None: + try: + sentence_transformers = importlib.import_module("sentence_transformers") + encoder = sentence_transformers.SentenceTransformer( + str(self._path), + device="cpu", + local_files_only=True, + trust_remote_code=False, + ) + except Exception as error: # optional runtime dependency / invalid model + raise EmbeddingProviderError("Local embedding model could not be loaded") from error + self._encoder = encoder + + @property + def dimensions(self) -> int: + return self._dimensions + + @property + def model_id(self) -> str: + return self._model_id + + @property + def artifact_sha256(self) -> str: + return self._artifact_sha256 + + def embed_queries(self, texts: tuple[str, ...]) -> tuple[tuple[float, ...], ...]: + return self._encode(texts, prefix="query: ") + + def embed_passages(self, texts: tuple[str, ...]) -> tuple[tuple[float, ...], ...]: + return self._encode(texts, prefix="passage: ") + + def _encode(self, texts: tuple[str, ...], *, prefix: str) -> tuple[tuple[float, ...], ...]: + if not texts: + return () + if any(not text.strip() for text in texts): + raise EmbeddingProviderError("Embedding input cannot be blank") + try: + encoded = self._encoder.encode( + [prefix + text for text in texts], + normalize_embeddings=True, + convert_to_numpy=True, + show_progress_bar=False, + ) + rows = tuple(tuple(float(value) for value in row) for row in encoded) + except Exception as error: + raise EmbeddingProviderError("Text could not be embedded") from error + if len(rows) != len(texts): + raise EmbeddingProviderError("Embedding provider returned an invalid row count") + for row in rows: + if len(row) != self._dimensions or not all(math.isfinite(value) for value in row): + raise EmbeddingProviderError("Embedding provider returned an invalid vector") + norm = math.sqrt(sum(value * value for value in row)) + if not math.isclose(norm, 1.0, rel_tol=1e-4, abs_tol=1e-4): + raise EmbeddingProviderError("Embedding provider returned a non-normalized vector") + return rows diff --git a/src/gewu/adapters/git_repository.py b/src/gewu/adapters/git_repository.py new file mode 100644 index 0000000..4bf5726 --- /dev/null +++ b/src/gewu/adapters/git_repository.py @@ -0,0 +1,199 @@ +"""Private bare-Git adapter with expected-head publication.""" + +from __future__ import annotations + +import os +import re +import secrets +import subprocess +from collections.abc import Mapping +from pathlib import Path + +from gewu.application.ports import ( + CanonicalConflictError, + CanonicalRepositoryError, + validate_canonical_path, +) + + +class GitRepositoryError(CanonicalRepositoryError): + """Safe repository operation failure.""" + + +class GitConflictError(CanonicalConflictError, GitRepositoryError): + """Expected-head compare-and-swap did not match.""" + + +class GitCanonicalRepository: + """Store accepted records in one authoritative Git ref without a worktree.""" + + branch_ref = "refs/heads/main" + + def __init__(self, path: Path) -> None: + self.path = path.expanduser().resolve() + self.path.parent.mkdir(parents=True, exist_ok=True, mode=0o700) + self.path.parent.chmod(0o700) + if self.path.exists(): + if not self.path.is_dir() or not (self.path / "HEAD").is_file(): + raise GitRepositoryError("Canonical repository path is not a bare Git repository") + else: + self.path.mkdir(mode=0o700) + self._run(["init", "--bare", "--initial-branch=main", str(self.path)], git_dir=False) + self.path.chmod(0o700) + tree = self._run(["mktree"], input_bytes=b"").strip() + empty_commit = self._run( + ["commit-tree", tree.decode("ascii"), "-m", "Initialize GEWU knowledge repository"], + env={ + "GIT_AUTHOR_NAME": "GEWU service", + "GIT_AUTHOR_EMAIL": "service@gewu.invalid", + "GIT_COMMITTER_NAME": "GEWU service", + "GIT_COMMITTER_EMAIL": "service@gewu.invalid", + }, + ).strip() + zero = "0" * len(empty_commit.strip()) + self._run(["update-ref", self.branch_ref, empty_commit.decode("ascii"), zero]) + + def _run( + self, + arguments: list[str], + *, + input_bytes: bytes | None = None, + env: Mapping[str, str] | None = None, + git_dir: bool = True, + ) -> bytes: + command = ["git"] + if git_dir: + command.extend([f"--git-dir={self.path}"]) + command.extend(arguments) + process_env = os.environ.copy() + process_env.update({"GIT_CONFIG_NOSYSTEM": "1", "GIT_TERMINAL_PROMPT": "0"}) + if env: + process_env.update(env) + result = subprocess.run( + command, + input=input_bytes, + capture_output=True, + env=process_env, + check=False, + ) + if result.returncode != 0: + # Keep command details and repository paths out of client-facing errors. + raise GitRepositoryError("Canonical repository operation failed") + return result.stdout + + def head(self) -> str: + return self._run(["rev-parse", self.branch_ref]).decode("ascii").strip() + + def read_file(self, revision: str, path: str) -> bytes | None: + self._validate_revision(revision) + self._validate_path(path) + result = subprocess.run( + ["git", f"--git-dir={self.path}", "show", f"{revision}:{path}"], + capture_output=True, + env={**os.environ, "GIT_CONFIG_NOSYSTEM": "1", "GIT_TERMINAL_PROMPT": "0"}, + check=False, + ) + if result.returncode != 0: + return None + return result.stdout + + def list_files(self, revision: str, prefix: str) -> tuple[str, ...]: + self._validate_revision(revision) + normalized_prefix = prefix.rstrip("/") + self._validate_path(normalized_prefix) + result = self._run( + ["ls-tree", "-r", "--name-only", "--full-tree", revision, "--", normalized_prefix] + ) + paths = tuple( + sorted( + path + for path in result.decode("utf-8").splitlines() + if path.startswith(normalized_prefix + "/") + ) + ) + for path in paths: + self._validate_path(path) + return paths + + def publish_files( + self, + *, + expected_head: str, + files: Mapping[str, bytes], + message: str, + index_directory: Path, + ) -> str: + """Create a commit containing only named files and CAS-publish the branch.""" + + if not re.fullmatch(r"(?:[a-f0-9]{40}|[a-f0-9]{64})", expected_head): + raise GitRepositoryError("Expected canonical revision is invalid") + if not files: + raise GitRepositoryError("Canonical publication requires at least one file") + for path in files: + self._validate_path(path) + + index_directory.mkdir(parents=True, exist_ok=True, mode=0o700) + if index_directory.is_symlink(): + raise GitRepositoryError("Private publication directory is unavailable") + index_path = index_directory / f"index-{secrets.token_hex(16)}" + env = { + "GIT_INDEX_FILE": str(index_path), + "GIT_AUTHOR_NAME": "GEWU service", + "GIT_AUTHOR_EMAIL": "service@gewu.invalid", + "GIT_COMMITTER_NAME": "GEWU service", + "GIT_COMMITTER_EMAIL": "service@gewu.invalid", + } + try: + self._run(["read-tree", expected_head], env=env) + for path, content in sorted(files.items()): + object_id = ( + self._run(["hash-object", "-w", "--stdin"], input_bytes=content) + .decode("ascii") + .strip() + ) + self._run( + ["update-index", "--add", "--cacheinfo", f"100644,{object_id},{path}"], env=env + ) + tree = self._run(["write-tree"], env=env).decode("ascii").strip() + commit = ( + self._run( + ["commit-tree", tree, "-p", expected_head, "-m", message[:200]], + env=env, + ) + .decode("ascii") + .strip() + ) + try: + self._run(["update-ref", self.branch_ref, commit, expected_head]) + except GitRepositoryError as error: + raise GitConflictError("Canonical branch changed before publication") from error + return commit + finally: + index_path.unlink(missing_ok=True) + + def introducing_commit(self, revision: str, path: str) -> str | None: + self._validate_revision(revision) + self._validate_path(path) + result = self._run(["log", "--reverse", "--format=%H", revision, "--", path]) + commits = result.decode("ascii").splitlines() + return commits[0] if commits else None + + def history(self, revision: str, path: str, limit: int) -> tuple[str, ...]: + self._validate_revision(revision) + self._validate_path(path) + if not 1 <= limit <= 50: + raise GitRepositoryError("Canonical history limit is invalid") + result = self._run(["log", "--format=%H", f"-n{limit}", revision, "--", path]) + return tuple(line for line in result.decode("ascii").splitlines() if line) + + @staticmethod + def _validate_path(path: str) -> None: + try: + validate_canonical_path(path) + except CanonicalRepositoryError as error: + raise GitRepositoryError(str(error)) from error + + @staticmethod + def _validate_revision(revision: str) -> None: + if not re.fullmatch(r"(?:[a-f0-9]{40}|[a-f0-9]{64})", revision): + raise GitRepositoryError("Canonical revision is invalid") diff --git a/src/gewu/adapters/pdf_extractor.py b/src/gewu/adapters/pdf_extractor.py new file mode 100644 index 0000000..a0b0209 --- /dev/null +++ b/src/gewu/adapters/pdf_extractor.py @@ -0,0 +1,211 @@ +"""Text extraction from PDFs through a bounded, networkless Linux sandbox.""" + +from __future__ import annotations + +import hashlib +import os +import shutil +import signal +import subprocess +import tempfile +from pathlib import Path +from typing import Protocol + +from gewu.adapters.text_extractor import ( + NORMALIZATION_PROFILE, + TextMarkdownExtractor, +) +from gewu.domain.contracts import ExtractionRecord +from gewu.domain.validation import canonical_json, extraction_digest + +MAX_PDF_BYTES = 50 * 1024 * 1024 +MAX_TEXT_BYTES = 50 * 1024 * 1024 +MAX_PAGES = 10_000 +TIMEOUT_SECONDS = 30 +PARSER_NAME = "gewu-poppler-pdftotext" +PARSER_VERSION = "sandboxed-system-poppler-v1" + + +class SourceExtractor(Protocol): + def extract( + self, source_revision_id: str, content: bytes, media_type: str + ) -> ExtractionRecord: ... + + +class PdfExtractionError(ValueError): + """PDF could not be safely or reliably converted to text.""" + + def __init__(self, code: str, message: str) -> None: + super().__init__(message) + self.code = code + + +class PdfTextExtractor: + """Run Poppler with only system libraries visible and resource limits enforced.""" + + def __init__(self) -> None: + self._bwrap = shutil.which("bwrap") + self._pdftotext = shutil.which("pdftotext") + self._prlimit = shutil.which("prlimit") + + def extract(self, source_revision_id: str, content: bytes, media_type: str) -> ExtractionRecord: + if media_type != "application/pdf": + raise PdfExtractionError( + "UNSUPPORTED_MEDIA_TYPE", "PDF extractor requires application/pdf" + ) + if not content or len(content) > MAX_PDF_BYTES or not content.startswith(b"%PDF-"): + raise PdfExtractionError("PDF_INVALID", "PDF signature or size is invalid") + if ( + not self._bwrap + or not self._pdftotext + or not self._prlimit + or not Path("/proc/sys/kernel/ostype").exists() + ): + raise PdfExtractionError( + "PARSER_UNAVAILABLE", "Sandboxed PDF extraction is unavailable" + ) + + assert self._pdftotext is not None + binary_digest = hashlib.sha256(Path(self._pdftotext).read_bytes()).hexdigest() + config = { + "sandbox": "bubblewrap-unshare-all-readonly-system-empty-env-tmpfs-v1", + "limits": { + "address_space": 1_073_741_824, + "cpu_seconds": 20, + "file_bytes": 104_857_600, + }, + "media_type": media_type, + "max_pages": MAX_PAGES, + "max_text_bytes": MAX_TEXT_BYTES, + "normalization": NORMALIZATION_PROFILE, + "poppler_binary_sha256": binary_digest, + "version": PARSER_VERSION, + } + try: + with tempfile.TemporaryFile() as output, tempfile.TemporaryFile() as errors: + process = subprocess.Popen( + self._command(), + stdin=subprocess.PIPE, + stdout=output, + stderr=errors, + env={}, + start_new_session=True, + close_fds=True, + ) + try: + process.communicate(input=content, timeout=TIMEOUT_SECONDS) + except subprocess.TimeoutExpired as error: + os.killpg(process.pid, signal.SIGKILL) + process.wait() + raise PdfExtractionError( + "PDF_TIMEOUT", "PDF extraction exceeded its time limit" + ) from error + if process.returncode != 0: + raise PdfExtractionError( + "PDF_UNSUPPORTED", "PDF is encrypted, malformed or unreadable" + ) + output.seek(0, os.SEEK_END) + if output.tell() > MAX_TEXT_BYTES: + raise PdfExtractionError( + "PDF_TEXT_TOO_LARGE", "Extracted PDF text exceeds its limit" + ) + output.seek(0) + extracted = output.read(MAX_TEXT_BYTES + 1).decode("utf-8", errors="strict") + except PdfExtractionError: + raise + except (OSError, UnicodeDecodeError, subprocess.SubprocessError) as error: + raise PdfExtractionError( + "PARSER_UNAVAILABLE", "Sandboxed PDF extraction failed" + ) from error + return self._record( + source_revision_id, extracted, hashlib.sha256(canonical_json(config)).hexdigest() + ) + + def _command(self) -> list[str]: + assert self._bwrap and self._pdftotext and self._prlimit + command = [ + self._bwrap, + "--unshare-all", + "--die-with-parent", + "--new-session", + "--ro-bind", + "/usr", + "/usr", + "--ro-bind", + "/bin", + "/bin", + "--ro-bind", + "/lib", + "/lib", + "--ro-bind", + "/lib64", + "/lib64", + "--dev", + "/dev", + "--proc", + "/proc", + "--tmpfs", + "/tmp", + "--chdir", + "/tmp", + "--clearenv", + "--setenv", + "LANG", + "C.UTF-8", + "--setenv", + "HOME", + "/tmp", + "--", + self._prlimit, + "--as=1073741824", + "--cpu=20", + "--fsize=104857600", + "--nofile=64", + "--", + self._pdftotext, + "-enc", + "UTF-8", + "-layout", + "-", + "-", + ] + return command + + @staticmethod + def _record(source_revision_id: str, text: str, config_digest: str) -> ExtractionRecord: + pages = text.replace("\r\n", "\n").replace("\r", "\n").split("\f") + if pages and not pages[-1].strip(): + pages.pop() + if not pages or len(pages) > MAX_PAGES: + raise PdfExtractionError( + "PDF_PAGE_LIMIT", "PDF page count is outside the supported limit" + ) + blocks = [] + ordinal = 0 + empty_pages = 0 + for page_number, page_text in enumerate(pages, start=1): + if not page_text.strip(): + empty_pages += 1 + continue + for _, chunk in TextMarkdownExtractor._split(page_text): + block = TextMarkdownExtractor._block(ordinal, chunk).model_copy( + update={"page": page_number} + ) + blocks.append(block) + ordinal += 1 + if not blocks: + raise PdfExtractionError("PDF_NO_TEXT", "PDF contains no extractable text") + candidate = ExtractionRecord( + extraction_id="0" * 64, + source_revision_id=source_revision_id, + parser_name=PARSER_NAME, + parser_version=PARSER_VERSION, + parser_config_sha256=config_digest, + normalization_profile=NORMALIZATION_PROFILE, + status="partial" if empty_pages else "complete", + blocks=tuple(blocks), + warnings=(f"{empty_pages} page(s) contained no extractable text",) + if empty_pages + else (), + ) + return candidate.model_copy(update={"extraction_id": extraction_digest(candidate)}) diff --git a/src/gewu/adapters/source_extractor.py b/src/gewu/adapters/source_extractor.py new file mode 100644 index 0000000..91294cf --- /dev/null +++ b/src/gewu/adapters/source_extractor.py @@ -0,0 +1,23 @@ +"""Select the versioned extractor appropriate for each archived source type.""" + +from __future__ import annotations + +from gewu.adapters.pdf_extractor import PdfTextExtractor, SourceExtractor +from gewu.adapters.text_extractor import TextMarkdownExtractor +from gewu.domain.contracts import ExtractionRecord + + +class LocalSourceExtractor: + """Compose local text and sandboxed PDF parsers behind one extraction contract.""" + + def __init__(self) -> None: + self.text = TextMarkdownExtractor() + self.pdf = PdfTextExtractor() + + def extract(self, source_revision_id: str, content: bytes, media_type: str) -> ExtractionRecord: + if media_type == "application/pdf": + return self.pdf.extract(source_revision_id, content, media_type) + return self.text.extract(source_revision_id, content, media_type) + + +__all__ = ["LocalSourceExtractor", "SourceExtractor"] diff --git a/src/gewu/adapters/sqlite_identity.py b/src/gewu/adapters/sqlite_identity.py new file mode 100644 index 0000000..e43a592 --- /dev/null +++ b/src/gewu/adapters/sqlite_identity.py @@ -0,0 +1,1323 @@ +"""Private SQLite credential registry with high-entropy revocable bearer keys.""" + +from __future__ import annotations + +import binascii +import hashlib +import hmac +import re +import secrets +import sqlite3 +from contextlib import AbstractContextManager, nullcontext +from datetime import UTC, datetime +from pathlib import Path +from typing import Any + +import portalocker +import pyotp +from argon2 import PasswordHasher +from argon2.exceptions import InvalidHashError, VerificationError, VerifyMismatchError +from cryptography.fernet import Fernet, InvalidToken + +from gewu.application.accounts import AccountSummary +from gewu.application.admin import AdminEvent, CredentialSummary +from gewu.application.identity import BrowserSession, OwnerSession, Principal +from gewu.application.paths import reject_symlink_components + +BROWSER_SESSION_TTL_SECONDS = 8 * 60 * 60 +OWNER_SESSION_TTL_SECONDS = 5 * 60 +OWNER_LOGIN_WINDOW_SECONDS = 15 * 60 +OWNER_LOGIN_MAX_FAILURES = 5 + + +class SQLiteIdentityStore: + """Provision and authenticate service credentials; secrets are returned once.""" + + def __init__( + self, + database: Path, + *, + authority_lock_path: Path | None = None, + owner_encryption_key: bytes | None = None, + ) -> None: + requested_database = database.expanduser().absolute() + reject_symlink_components(requested_database) + self.database = requested_database + self._owner_cipher = Fernet(owner_encryption_key) if owner_encryption_key else None + self._password_hasher = PasswordHasher() + self._dummy_password_hash = self._password_hasher.hash(secrets.token_urlsafe(32)) + self.authority_lock_path = ( + authority_lock_path.expanduser().absolute() if authority_lock_path is not None else None + ) + if self.authority_lock_path is not None: + reject_symlink_components(self.authority_lock_path) + self.authority_lock_path.parent.mkdir(parents=True, exist_ok=True, mode=0o700) + self.authority_lock_path.parent.chmod(0o700) + self.database.parent.mkdir(parents=True, exist_ok=True, mode=0o700) + self.database.parent.chmod(0o700) + if self.database.is_symlink(): + raise RuntimeError("Identity database must not be a symbolic link") + with self._connect() as connection: + connection.execute( + """CREATE TABLE IF NOT EXISTS credentials ( + credential_id TEXT PRIMARY KEY, + principal_id TEXT NOT NULL, + token_digest TEXT NOT NULL UNIQUE, + roles TEXT NOT NULL, + project_ids TEXT NOT NULL, + revoked INTEGER NOT NULL DEFAULT 0 + )""" + ) + connection.execute( + "CREATE TABLE IF NOT EXISTS browser_sessions (" + "session_digest TEXT PRIMARY KEY, csrf_digest TEXT NOT NULL, " + "credential_id TEXT NOT NULL REFERENCES credentials(credential_id), " + "expires_at INTEGER NOT NULL, revoked INTEGER NOT NULL DEFAULT 0)" + ) + session_columns = { + row[1] for row in connection.execute("PRAGMA table_info(browser_sessions)") + } + if "restored_csrf_digest" not in session_columns: + connection.execute( + "ALTER TABLE browser_sessions ADD COLUMN restored_csrf_digest TEXT" + ) + connection.execute( + "CREATE INDEX IF NOT EXISTS browser_sessions_credential " + "ON browser_sessions(credential_id, revoked, expires_at)" + ) + connection.execute( + """CREATE TABLE IF NOT EXISTS owner_accounts ( + principal_id TEXT PRIMARY KEY, + password_hash TEXT NOT NULL, + encrypted_totp_secret BLOB NOT NULL, + project_ids TEXT NOT NULL, + grant_version INTEGER NOT NULL, + totp_last_counter INTEGER NOT NULL DEFAULT -1, + active INTEGER NOT NULL DEFAULT 1 + )""" + ) + connection.execute( + """CREATE TABLE IF NOT EXISTS owner_sessions ( + session_digest TEXT PRIMARY KEY, + principal_id TEXT NOT NULL REFERENCES owner_accounts(principal_id), + grant_version INTEGER NOT NULL, + challenge_id TEXT NOT NULL, + audience TEXT NOT NULL, + expires_at INTEGER NOT NULL, + revoked INTEGER NOT NULL DEFAULT 0 + )""" + ) + connection.execute( + "CREATE INDEX IF NOT EXISTS owner_sessions_active " + "ON owner_sessions(principal_id, revoked, expires_at)" + ) + connection.execute( + """CREATE TABLE IF NOT EXISTS owner_challenges ( + challenge_id TEXT PRIMARY KEY, + owner_principal_id TEXT NOT NULL REFERENCES owner_accounts(principal_id), + proposal_id TEXT NOT NULL, + proposal_revision INTEGER NOT NULL, + payload_digest TEXT NOT NULL, + diff_digest TEXT NOT NULL, + domains TEXT NOT NULL, + grant_version INTEGER NOT NULL, + expires_at INTEGER NOT NULL, + status TEXT NOT NULL, + created_at INTEGER NOT NULL, + diff_json TEXT NOT NULL DEFAULT '{}', + decision TEXT, + rationale TEXT, + decided_at INTEGER, + decision_digest TEXT, + owner_session_id TEXT + )""" + ) + # Existing local databases predate the full owner-review lifecycle. + # Additive columns preserve issued challenge bindings during upgrade. + challenge_columns = { + row[1] for row in connection.execute("PRAGMA table_info(owner_challenges)") + } + for name, declaration in ( + ("diff_json", "TEXT NOT NULL DEFAULT '{}'"), + ("decision", "TEXT"), + ("rationale", "TEXT"), + ("decided_at", "INTEGER"), + ("decision_digest", "TEXT"), + ("owner_session_id", "TEXT"), + ): + if name not in challenge_columns: + connection.execute( + f"ALTER TABLE owner_challenges ADD COLUMN {name} {declaration}" + ) + connection.execute( + """CREATE TABLE IF NOT EXISTS owner_login_failures ( + principal_id TEXT NOT NULL, + failed_at INTEGER NOT NULL + )""" + ) + connection.execute( + "CREATE INDEX IF NOT EXISTS owner_login_failures_window " + "ON owner_login_failures(principal_id, failed_at)" + ) + connection.execute( + """CREATE TABLE IF NOT EXISTS owner_security_events ( + event_id TEXT PRIMARY KEY, + principal_id TEXT NOT NULL, + event_type TEXT NOT NULL, + happened_at INTEGER NOT NULL + )""" + ) + connection.execute( + "CREATE TABLE IF NOT EXISTS admin_events (" + "event_id INTEGER PRIMARY KEY AUTOINCREMENT, occurred_at TEXT NOT NULL, " + "actor TEXT NOT NULL, action TEXT NOT NULL, credential_id TEXT NOT NULL)" + ) + credential_columns = { + row[1] for row in connection.execute("PRAGMA table_info(credentials)") + } + if "owner_principal_id" not in credential_columns: + connection.execute("ALTER TABLE credentials ADD COLUMN owner_principal_id TEXT") + connection.execute( + "CREATE TABLE IF NOT EXISTS user_accounts (username TEXT PRIMARY KEY, " + "credential_id TEXT NOT NULL UNIQUE REFERENCES credentials(credential_id), " + "password_hash TEXT NOT NULL)" + ) + connection.execute( + "CREATE TABLE IF NOT EXISTS account_login_failures (username TEXT NOT NULL, " + "failed_at INTEGER NOT NULL)" + ) + connection.execute( + "CREATE INDEX IF NOT EXISTS account_login_failure_window " + "ON account_login_failures(failed_at, username)" + ) + self.database.chmod(0o600) + + def update_credential( + self, credential_id: str, *, role: str, project_ids: tuple[str, ...], actor: str + ) -> CredentialSummary | None: + if ( + role not in {"admin", "editor", "viewer", "agent"} + or len(project_ids) > 50 + or len(set(project_ids)) != len(project_ids) + ): + raise ValueError("Credential role or project grants are invalid") + with self._authority_lock(): + with self._connect() as connection: + row = connection.execute( + "SELECT principal_id FROM credentials WHERE credential_id=? AND revoked=0", + (credential_id,), + ).fetchone() + if row is None: + return None + connection.execute( + "UPDATE credentials SET roles=?, project_ids=? WHERE credential_id=?", + (role, ",".join(sorted(project_ids)), credential_id), + ) + self._record_admin_event(connection, actor, "credential.updated", credential_id) + result = connection.execute( + "SELECT credential_id, principal_id, roles, project_ids, revoked, " + "owner_principal_id FROM credentials WHERE credential_id=?", + (credential_id,), + ).fetchone() + return CredentialSummary( + result[0], + result[1], + result[2].split(","), + list(filter(None, result[3].split(","))), + bool(result[4]), + result[5], + ) + + def list_credentials(self, *, limit: int, offset: int) -> list[CredentialSummary]: + with self._connect() as connection: + rows = connection.execute( + "SELECT credential_id, principal_id, roles, project_ids, revoked, " + "owner_principal_id " + "FROM credentials ORDER BY credential_id LIMIT ? OFFSET ?", + (limit, offset), + ).fetchall() + return [ + CredentialSummary( + row[0], + row[1], + row[2].split(","), + list(filter(None, row[3].split(","))), + bool(row[4]), + row[5], + ) + for row in rows + ] + + def list_admin_events(self, *, limit: int) -> list[AdminEvent]: + with self._connect() as connection: + rows = connection.execute( + "SELECT event_id, occurred_at, actor, action, credential_id " + "FROM admin_events ORDER BY event_id DESC LIMIT ?", + (limit,), + ).fetchall() + return [AdminEvent(*row) for row in rows] + + @staticmethod + def _record_admin_event( + connection: sqlite3.Connection, actor: str, action: str, credential_id: str + ) -> None: + connection.execute( + "INSERT INTO admin_events(occurred_at, actor, action, credential_id) " + "VALUES (?, ?, ?, ?)", + (datetime.now(UTC).isoformat(), actor, action, credential_id), + ) + + def _connect(self) -> sqlite3.Connection: + connection = sqlite3.connect(self.database, timeout=5, isolation_level="IMMEDIATE") + connection.execute("PRAGMA foreign_keys=ON") + return connection + + @property + def owner_authentication_enabled(self) -> bool: + return self._owner_cipher is not None + + def provision( + self, + *, + credential_id: str, + principal_id: str, + roles: tuple[str, ...], + project_ids: tuple[str, ...] = (), + actor: str | None = None, + ) -> str: + """Provision a credential, returning its secret once.""" + + allowed_roles = {"admin", "editor", "viewer", "agent"} + safe_id = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$") + if ( + not safe_id.fullmatch(credential_id) + or not safe_id.fullmatch(principal_id) + or any(not safe_id.fullmatch(project_id) for project_id in project_ids) + or not roles + or set(roles) - allowed_roles + ): + raise ValueError("Credential identity or role set is invalid") + if len(set(roles)) != len(roles) or len(set(project_ids)) != len(project_ids): + raise ValueError("Credential grants must be unique") + token = "gwu_" + secrets.token_urlsafe(32) + digest = hashlib.sha256(token.encode("ascii")).hexdigest() + with self._connect() as connection: + connection.execute( + "INSERT INTO credentials(credential_id, principal_id, token_digest, roles, " + "project_ids, " + "revoked) VALUES (?, ?, ?, ?, ?, 0)", + ( + credential_id, + principal_id, + digest, + ",".join(sorted(roles)), + ",".join(sorted(project_ids)), + ), + ) + if actor is not None: + self._record_admin_event(connection, actor, "credential.created", credential_id) + self.database.chmod(0o600) + return token + + def authenticate(self, bearer_token: str) -> Principal | None: + if len(bearer_token) > 128 or not bearer_token.startswith("gwu_"): + return None + digest = hashlib.sha256(bearer_token.encode("ascii", errors="ignore")).hexdigest() + with self._connect() as connection: + row = connection.execute( + "SELECT credential_id, principal_id, roles, project_ids FROM credentials " + "WHERE token_digest=? AND revoked=0", + (digest,), + ).fetchone() + if row is None: + return None + credential_id, principal_id, roles, project_ids = row + return Principal( + principal_id=principal_id, + credential_id=credential_id, + roles=frozenset(roles.split(",")), + project_ids=frozenset(filter(None, project_ids.split(","))), + ) + + def create_account( + self, + *, + username: str, + password: str, + role: str, + project_ids: tuple[str, ...], + actor: str, + ) -> AccountSummary: + if ( + not re.fullmatch(r"[a-z0-9][a-z0-9._-]{0,63}", username) + or not 12 <= len(password) <= 1024 + or role not in {"admin", "editor", "viewer"} + or len(project_ids) > 50 + or len(set(project_ids)) != len(project_ids) + or any(not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9._:-]{0,127}", p) for p in project_ids) + ): + raise ValueError("Account identity, password or grants are invalid") + password_hash = self._password_hasher.hash(password) + credential_id = "account:" + username + principal_id = "user:" + username + with self._connect() as connection: + connection.execute( + "INSERT INTO credentials(credential_id, principal_id, token_digest, roles, " + "project_ids, revoked) VALUES (?, ?, ?, ?, ?, 0)", + ( + credential_id, + principal_id, + hashlib.sha256(secrets.token_bytes(32)).hexdigest(), + role, + ",".join(sorted(project_ids)), + ), + ) + connection.execute( + "INSERT INTO user_accounts VALUES (?, ?, ?)", + (username, credential_id, password_hash), + ) + self._record_admin_event(connection, actor, "account.created", credential_id) + return AccountSummary(username, principal_id, role, sorted(project_ids), True) + + def list_accounts(self, *, limit: int, offset: int) -> list[AccountSummary]: + with self._connect() as connection: + rows = connection.execute( + "SELECT u.username, c.principal_id, c.roles, c.project_ids, c.revoked " + "FROM user_accounts u JOIN credentials c USING(credential_id) " + "ORDER BY u.username LIMIT ? OFFSET ?", + (limit, offset), + ).fetchall() + return [ + AccountSummary(r[0], r[1], r[2], list(filter(None, r[3].split(","))), not r[4]) + for r in rows + ] + + def _password_valid(self, password_hash: str, password: str) -> bool: + try: + return self._password_hasher.verify(password_hash, password) + except (InvalidHashError, VerificationError, VerifyMismatchError): + return False + + def login_account(self, username: str, password: str) -> BrowserSession | None: + now = int(datetime.now(UTC).timestamp()) + with self._connect() as connection: + connection.execute( + "DELETE FROM account_login_failures WHERE failed_at<=?", + (now - OWNER_LOGIN_WINDOW_SECONDS,), + ) + failures = connection.execute( + "SELECT COUNT(*) FROM account_login_failures WHERE username=?", + (username,), + ).fetchone()[0] + total_failures = connection.execute( + "SELECT COUNT(*) FROM account_login_failures" + ).fetchone()[0] + if failures >= OWNER_LOGIN_MAX_FAILURES or total_failures >= 100: + return None + # Acquire a write transaction before password verification to serialize attempts. + row = connection.execute( + "SELECT u.credential_id, u.password_hash FROM user_accounts u " + "JOIN credentials c USING(credential_id) WHERE u.username=? AND c.revoked=0", + (username,), + ).fetchone() + valid = self._password_valid(row[1] if row else self._dummy_password_hash, password) + if row is None or not valid: + connection.execute( + "INSERT INTO account_login_failures VALUES (?, ?)", (username, now) + ) + return None + connection.execute("DELETE FROM account_login_failures WHERE username=?", (username,)) + return self._insert_browser_session(connection, row[0]) + + @staticmethod + def _require_account(connection: sqlite3.Connection, principal: Principal) -> tuple[str, str]: + row = connection.execute( + "SELECT u.username, u.password_hash FROM user_accounts u " + "JOIN credentials c USING(credential_id) WHERE c.credential_id=? " + "AND c.principal_id=? AND c.revoked=0", + (principal.credential_id, principal.principal_id), + ).fetchone() + if row is None or principal.auth_method != "browser_session": + raise PermissionError("Sign in with a username and password to manage your account") + return row[0], row[1] + + def change_account_password( + self, principal: Principal, current_password: str, new_password: str + ) -> bool: + if not 12 <= len(new_password) <= 1024: + raise ValueError("Password must contain between 12 and 1024 characters") + password_hash = self._password_hasher.hash(new_password) + with self._authority_lock(): + with self._connect() as connection: + _, current_hash = self._require_account(connection, principal) + if not self._password_valid(current_hash, current_password): + return False + connection.execute( + "UPDATE user_accounts SET password_hash=? WHERE credential_id=?", + (password_hash, principal.credential_id), + ) + connection.execute( + "UPDATE browser_sessions SET revoked=1 WHERE credential_id=?", + (principal.credential_id,), + ) + self._record_admin_event( + connection, + principal.principal_id, + "account.password_changed", + principal.credential_id, + ) + return True + + def update_account( + self, username: str, *, role: str, project_ids: tuple[str, ...], actor: Principal + ) -> AccountSummary | None: + if ( + role not in {"admin", "editor", "viewer"} + or len(project_ids) > 50 + or len(set(project_ids)) != len(project_ids) + ): + raise ValueError("Account role or project grants are invalid") + with self._authority_lock(): + with self._connect() as connection: + row = connection.execute( + "SELECT c.credential_id, c.principal_id, c.revoked FROM user_accounts u " + "JOIN credentials c USING(credential_id) WHERE u.username=?", + (username,), + ).fetchone() + if row is None or row[2]: + return None + if row[1] == actor.principal_id: + raise PermissionError( + "Use another administrator account to change your account role" + ) + connection.execute( + "UPDATE credentials SET roles=?, project_ids=? WHERE credential_id=?", + (role, ",".join(sorted(project_ids)), row[0]), + ) + self._record_admin_event(connection, actor.principal_id, "account.updated", row[0]) + active = not row[2] + return AccountSummary(username, row[1], role, sorted(project_ids), active) + + def disable_account(self, username: str, *, actor: Principal) -> bool: + with self._authority_lock(): + with self._connect() as connection: + row = connection.execute( + "SELECT c.credential_id, c.principal_id FROM user_accounts u " + "JOIN credentials c USING(credential_id) WHERE u.username=? AND c.revoked=0", + (username,), + ).fetchone() + if row is None: + return False + if row[1] == actor.principal_id: + raise PermissionError( + "Use another administrator account to disable your account" + ) + connection.execute( + "UPDATE credentials SET revoked=1 WHERE credential_id=? " + "OR owner_principal_id=?", + row, + ) + self._record_admin_event(connection, actor.principal_id, "account.disabled", row[0]) + return True + + def list_agent_credentials( + self, principal: Principal, *, limit: int, offset: int + ) -> list[CredentialSummary]: + with self._connect() as connection: + self._require_account(connection, principal) + rows = connection.execute( + "SELECT credential_id, principal_id, roles, project_ids, revoked, " + "owner_principal_id " + "FROM credentials " + "WHERE owner_principal_id=? ORDER BY credential_id LIMIT ? OFFSET ?", + (principal.principal_id, limit, offset), + ).fetchall() + return [ + CredentialSummary( + r[0], r[1], r[2].split(","), list(filter(None, r[3].split(","))), bool(r[4]), r[5] + ) + for r in rows + ] + + def create_agent_credential( + self, principal: Principal, *, name: str, project_ids: tuple[str, ...] + ) -> tuple[str, str]: + if ( + not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9._-]{0,63}", name) + or not project_ids + or len(project_ids) > 50 + or len(set(project_ids)) != len(project_ids) + or any(not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9._:-]{0,127}", p) for p in project_ids) + ): + raise ValueError("Agent name or project grants are invalid") + credential_id = "agent:" + name + ":" + secrets.token_hex(8) + token = "gwu_" + secrets.token_urlsafe(32) + with self._authority_lock(): + with self._connect() as connection: + self._require_account(connection, principal) + row = connection.execute( + "SELECT roles, project_ids FROM credentials " + "WHERE credential_id=? AND revoked=0", + (principal.credential_id,), + ).fetchone() + if row is None or row[0] not in {"admin", "editor"}: + raise PermissionError( + "Editor or administrator access is required to issue agent keys" + ) + if row[0] != "admin" and not set(project_ids).issubset(row[1].split(",")): + raise PermissionError("Agent grants exceed your project access") + connection.execute( + "INSERT INTO credentials(credential_id, principal_id, token_digest, roles, " + "project_ids, revoked, owner_principal_id) VALUES (?, ?, ?, 'agent', ?, 0, ?)", + ( + credential_id, + credential_id, + hashlib.sha256(token.encode("ascii")).hexdigest(), + ",".join(sorted(project_ids)), + principal.principal_id, + ), + ) + self._record_admin_event( + connection, principal.principal_id, "credential.created", credential_id + ) + return credential_id, token + + def revoke_agent_credential(self, principal: Principal, credential_id: str) -> bool: + with self._authority_lock(): + with self._connect() as connection: + self._require_account(connection, principal) + cursor = connection.execute( + "UPDATE credentials SET revoked=1 WHERE credential_id=? " + "AND owner_principal_id=? AND revoked=0", + (credential_id, principal.principal_id), + ) + if cursor.rowcount: + self._record_admin_event( + connection, principal.principal_id, "credential.revoked", credential_id + ) + return cursor.rowcount == 1 + + def provision_owner( + self, + *, + principal_id: str, + password: str, + totp_secret: str, + project_ids: tuple[str, ...], + ) -> None: + """Restricted operator provisioning primitive; no network route exposes it.""" + + if self._owner_cipher is None: + raise RuntimeError("Owner encryption key is not configured") + safe_id = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$") + if ( + not safe_id.fullmatch(principal_id) + or any(not safe_id.fullmatch(project_id) for project_id in project_ids) + or len(set(project_ids)) != len(project_ids) + or len(password) < 12 + or len(password) > 1024 + or not totp_secret + ): + raise ValueError("Owner account data is invalid") + try: + encoded_secret = totp_secret.encode("ascii") + pyotp.TOTP(totp_secret).now() + except (ValueError, TypeError, UnicodeEncodeError, binascii.Error) as error: + raise ValueError("Owner TOTP secret is invalid") from error + encrypted_secret = self._owner_cipher.encrypt(encoded_secret) + password_hash = self._password_hasher.hash(password) + with self._connect() as connection: + connection.execute( + "INSERT INTO owner_accounts(principal_id, password_hash, encrypted_totp_secret, " + "project_ids, grant_version, totp_last_counter, active) " + "VALUES (?, ?, ?, ?, 1, -1, 1)", + (principal_id, password_hash, encrypted_secret, ",".join(sorted(project_ids))), + ) + connection.execute( + "INSERT INTO owner_security_events VALUES (?, ?, 'owner-provisioned', ?)", + (secrets.token_hex(16), principal_id, int(datetime.now(UTC).timestamp())), + ) + self.database.chmod(0o600) + + def update_owner_grants(self, principal_id: str, project_ids: tuple[str, ...]) -> bool: + """Replace an owner's explicit project grants and invalidate active sessions.""" + + safe_id = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$") + if ( + not safe_id.fullmatch(principal_id) + or any(not safe_id.fullmatch(project_id) for project_id in project_ids) + or len(set(project_ids)) != len(project_ids) + ): + raise ValueError("Owner grants are invalid") + with self._authority_lock(): + with self._connect() as connection: + cursor = connection.execute( + "UPDATE owner_accounts SET project_ids=?, grant_version=grant_version+1 " + "WHERE principal_id=? AND active=1", + (",".join(sorted(project_ids)), principal_id), + ) + if cursor.rowcount != 1: + return False + connection.execute( + "UPDATE owner_sessions SET revoked=1 WHERE principal_id=? AND revoked=0", + (principal_id,), + ) + connection.execute( + "INSERT INTO owner_security_events VALUES (?, ?, 'owner-grants-updated', ?)", + (secrets.token_hex(16), principal_id, int(datetime.now(UTC).timestamp())), + ) + return True + + def revoke_owner(self, principal_id: str) -> bool: + """Disable an owner and revoke every active owner session.""" + + with self._authority_lock(): + with self._connect() as connection: + cursor = connection.execute( + "UPDATE owner_accounts SET active=0, grant_version=grant_version+1 " + "WHERE principal_id=? AND active=1", + (principal_id,), + ) + if cursor.rowcount != 1: + return False + connection.execute( + "UPDATE owner_sessions SET revoked=1 WHERE principal_id=? AND revoked=0", + (principal_id,), + ) + connection.execute( + "INSERT INTO owner_security_events VALUES (?, ?, 'owner-revoked', ?)", + (secrets.token_hex(16), principal_id, int(datetime.now(UTC).timestamp())), + ) + return True + + def authenticate_owner( + self, + *, + principal_id: str, + password: str, + totp_code: str, + challenge_id: str, + audience: str, + now: int | None = None, + ) -> OwnerSession | None: + """Issue a short-lived session bound to one challenge and service audience.""" + + if ( + self._owner_cipher is None + or not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9._:-]{0,127}", principal_id) + or not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9._:-]{0,127}", challenge_id) + or not audience + or len(audience) > 256 + or len(password) > 1024 + or not re.fullmatch(r"\d{6}", totp_code) + ): + return None + current = int(datetime.now(UTC).timestamp()) if now is None else now + with self._connect() as connection: + challenge = connection.execute( + "SELECT expires_at, status FROM owner_challenges WHERE challenge_id=? " + "AND owner_principal_id=?", + (challenge_id, principal_id), + ).fetchone() + if challenge is None or challenge[0] <= current or challenge[1] != "issued": + return None + failures = connection.execute( + "SELECT COUNT(*) FROM owner_login_failures WHERE principal_id=? AND failed_at>?", + (principal_id, current - OWNER_LOGIN_WINDOW_SECONDS), + ).fetchone()[0] + if failures >= OWNER_LOGIN_MAX_FAILURES: + return None + row = connection.execute( + "SELECT password_hash, encrypted_totp_secret, project_ids, grant_version, " + "totp_last_counter FROM owner_accounts " + "WHERE principal_id=? AND active=1", + (principal_id,), + ).fetchone() + password_hash = row[0] if row is not None else self._dummy_password_hash + try: + password_valid: bool = self._password_hasher.verify(password_hash, password) + except (VerifyMismatchError, VerificationError, InvalidHashError): + password_valid = False + secret: str | None = None + if row is not None and password_valid: + try: + secret = self._owner_cipher.decrypt(row[1]).decode("ascii") + except (InvalidToken, UnicodeDecodeError): + secret = None + match_counter: int | None = None + if secret is not None: + try: + totp = pyotp.TOTP(secret) + current_counter = int(current // totp.interval) + for counter in (current_counter - 1, current_counter, current_counter + 1): + if counter >= 0 and hmac.compare_digest( + totp.generate_otp(counter), totp_code + ): + match_counter = counter + break + except (ValueError, TypeError, UnicodeEncodeError, binascii.Error): + match_counter = None + if row is None or not password_valid or match_counter is None: + connection.execute( + "DELETE FROM owner_login_failures WHERE failed_at<=?", + (current - OWNER_LOGIN_WINDOW_SECONDS,), + ) + if row is not None: + connection.execute( + "INSERT INTO owner_login_failures(principal_id, failed_at) VALUES (?, ?)", + (principal_id, current), + ) + return None + challenge_grant_version = connection.execute( + "SELECT grant_version FROM owner_challenges WHERE challenge_id=? " + "AND owner_principal_id=? AND status='issued' AND expires_at>?", + (challenge_id, principal_id, current), + ).fetchone() + if challenge_grant_version is None or challenge_grant_version[0] != row[3]: + return None + if match_counter <= row[4]: + return None + connection.execute( + "UPDATE owner_accounts SET totp_last_counter=? WHERE principal_id=? " + "AND active=1 AND totp_last_counter Principal | None: + if len(session_token) > 128 or not session_token.startswith("gwo_"): + return None + digest = hashlib.sha256(session_token.encode("ascii", errors="ignore")).hexdigest() + now = int(datetime.now(UTC).timestamp()) + with self._connect() as connection: + row = connection.execute( + "SELECT a.principal_id, a.project_ids, s.session_digest FROM owner_sessions s " + "JOIN owner_accounts a USING(principal_id) " + "JOIN owner_challenges ch ON ch.challenge_id=s.challenge_id " + "AND ch.owner_principal_id=s.principal_id " + "WHERE s.session_digest=? AND s.challenge_id=? AND s.audience=? " + "AND s.revoked=0 AND s.expires_at>? AND a.active=1 " + "AND s.grant_version=a.grant_version " + "AND ch.status IN ('issued', 'authorized', 'rejected') " + "AND ch.expires_at>? AND ch.grant_version=a.grant_version", + (digest, challenge_id, audience, now, now), + ).fetchone() + if row is None: + return None + return Principal( + principal_id=row[0], + credential_id=f"owner-session:{row[2]}", + roles=frozenset({"owner"}), + project_ids=frozenset(filter(None, row[1].split(","))), + auth_method="owner_session", + ) + + def issue_owner_challenge( + self, + *, + challenge_id: str, + owner_principal_id: str, + proposal_id: str, + proposal_revision: int, + payload_digest: str, + diff_digest: str, + domains: tuple[str, ...], + expires_at: int, + diff_json: str | None = None, + now: int | None = None, + ) -> None: + """Persist an immutable, owner-scoped challenge binding for later review.""" + + safe_id = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$") + if ( + not all( + safe_id.fullmatch(value) + for value in (challenge_id, owner_principal_id, proposal_id, *domains) + ) + or not domains + or len(set(domains)) != len(domains) + or type(proposal_revision) is not int + or proposal_revision < 1 + or not re.fullmatch(r"[a-f0-9]{64}", payload_digest) + or not re.fullmatch(r"[a-f0-9]{64}", diff_digest) + or (diff_json is not None and len(diff_json.encode("utf-8")) > 512_000) + or ( + diff_json is not None + and hashlib.sha256(diff_json.encode("utf-8")).hexdigest() != diff_digest + ) + ): + raise ValueError("Owner challenge binding is invalid") + current = int(datetime.now(UTC).timestamp()) if now is None else now + if expires_at <= current or expires_at > current + 15 * 60: + raise ValueError("Owner challenge expiry is invalid") + with self._connect() as connection: + account = connection.execute( + "SELECT project_ids, grant_version FROM owner_accounts " + "WHERE principal_id=? AND active=1", + (owner_principal_id,), + ).fetchone() + if account is None or not set(domains) <= set(filter(None, account[0].split(","))): + raise PermissionError("Owner is not granted every affected domain") + values = ( + challenge_id, + owner_principal_id, + proposal_id, + proposal_revision, + payload_digest, + diff_digest, + ",".join(sorted(domains)), + account[1], + expires_at, + "issued", + current, + ) + existing = connection.execute( + "SELECT challenge_id, owner_principal_id, proposal_id, proposal_revision, " + "payload_digest, diff_digest, domains, grant_version, expires_at, status, " + "created_at, diff_json FROM owner_challenges WHERE challenge_id=?", + (challenge_id,), + ).fetchone() + if existing is not None: + if tuple(existing[:8]) != values[:8] or ( + diff_json is not None and existing[11] != diff_json + ): + raise ValueError("Owner challenge ID already binds different content") + return + connection.execute( + "INSERT INTO owner_challenges(challenge_id, owner_principal_id, proposal_id, " + "proposal_revision, payload_digest, diff_digest, domains, grant_version, " + "expires_at, status, created_at, diff_json) " + "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + (*values, diff_json or "{}"), + ) + connection.execute( + "INSERT INTO owner_security_events VALUES (?, ?, 'owner-challenge-issued', ?)", + (secrets.token_hex(16), owner_principal_id, current), + ) + + def get_owner_challenge(self, challenge_id: str, *, principal_id: str) -> dict[str, Any] | None: + """Return a bounded challenge only to its named owner while grants remain current.""" + + now = int(datetime.now(UTC).timestamp()) + with self._connect() as connection: + row = connection.execute( + "SELECT ch.proposal_id, ch.proposal_revision, ch.payload_digest, " + "ch.diff_digest, ch.domains, ch.grant_version, ch.expires_at, ch.status, " + "ch.diff_json, ch.decision, ch.rationale, ch.decided_at, ch.decision_digest, " + "ch.owner_session_id, " + "a.project_ids, a.grant_version, a.active FROM owner_challenges ch " + "JOIN owner_accounts a ON a.principal_id=ch.owner_principal_id " + "WHERE ch.challenge_id=? AND ch.owner_principal_id=?", + (challenge_id, principal_id), + ).fetchone() + if row is None or not row[16] or row[5] != row[15]: + return None + if not set(filter(None, row[4].split(","))) <= set(filter(None, row[14].split(","))): + return None + if not hmac.compare_digest(hashlib.sha256(row[8].encode("utf-8")).hexdigest(), row[3]): + return None + return { + "challenge_id": challenge_id, + "proposal_id": row[0], + "proposal_revision": row[1], + "payload_digest": row[2], + "diff_digest": row[3], + "domains": tuple(filter(None, row[4].split(","))), + "expires_at": row[6], + "status": "expired" if row[6] <= now and row[7] == "issued" else row[7], + "diff_json": row[8], + "decision": row[9], + "rationale": row[10], + "decided_at": row[11], + "decision_digest": row[12], + "owner_session_id": row[13], + "grant_version": row[5], + } + + def authorized_owner_challenges( + self, + *, + proposal_id: str, + proposal_revision: int, + payload_digest: str, + diff_digest: str, + ) -> tuple[dict[str, object], ...]: + """List current, authorized challenge bindings for acceptance revalidation.""" + + now = int(datetime.now(UTC).timestamp()) + with self._connect() as connection: + rows = connection.execute( + "SELECT ch.challenge_id, ch.owner_principal_id, ch.domains, " + "ch.grant_version, ch.decision_digest, ch.decision, ch.rationale, " + "ch.decided_at, ch.expires_at, ch.owner_session_id FROM owner_challenges ch " + "JOIN owner_accounts a ON a.principal_id=ch.owner_principal_id " + "WHERE ch.proposal_id=? AND ch.proposal_revision=? AND ch.payload_digest=? " + "AND ch.diff_digest=? AND ch.status='authorized' AND ch.expires_at>? " + "AND a.active=1 AND a.grant_version=ch.grant_version", + (proposal_id, proposal_revision, payload_digest, diff_digest, now), + ).fetchall() + return tuple( + { + "challenge_id": row[0], + "owner_principal_id": row[1], + "domains": tuple(filter(None, row[2].split(","))), + "grant_version": row[3], + "decision_digest": row[4], + "decision": row[5], + "rationale": row[6], + "decided_at": row[7], + "expires_at": row[8], + "owner_session_id": row[9], + } + for row in rows + ) + + def decide_owner_challenge( + self, + *, + challenge_id: str, + principal_id: str, + proposal_revision: int, + payload_digest: str, + diff_digest: str, + decision: str, + rationale: str, + owner_session_id: str, + audience: str, + now: int | None = None, + ) -> dict[str, Any]: + """Record one immutable, exact-diff owner decision; exact retries are idempotent.""" + + if ( + decision not in {"authorize", "reject"} + or not rationale.strip() + or len(rationale) > 2000 + or not re.fullmatch(r"[a-f0-9]{64}", payload_digest) + or not re.fullmatch(r"[a-f0-9]{64}", diff_digest) + or not re.fullmatch(r"[a-f0-9]{64}", owner_session_id) + or not audience + or len(audience) > 256 + ): + raise ValueError("Owner decision is invalid") + current = int(datetime.now(UTC).timestamp()) if now is None else now + decision_payload = "\n".join( + ( + challenge_id, + principal_id, + str(proposal_revision), + payload_digest, + diff_digest, + decision, + rationale.strip(), + owner_session_id, + audience, + ) + ) + decision_digest = hashlib.sha256(decision_payload.encode("utf-8")).hexdigest() + with self._authority_lock(): + with self._connect() as connection: + row = connection.execute( + "SELECT ch.proposal_revision, ch.payload_digest, ch.diff_digest, ch.domains, " + "ch.grant_version, ch.expires_at, ch.status, ch.decision_digest, " + "ch.decision, ch.rationale, a.project_ids, a.grant_version, a.active, " + "EXISTS(SELECT 1 FROM owner_sessions s WHERE s.session_digest=? " + "AND s.principal_id=a.principal_id AND s.challenge_id=ch.challenge_id " + "AND s.audience=? AND s.revoked=0 AND s.expires_at>? " + "AND s.grant_version=a.grant_version) " + "FROM owner_challenges ch JOIN owner_accounts a " + "ON a.principal_id=ch.owner_principal_id " + "WHERE ch.challenge_id=? AND ch.owner_principal_id=?", + (owner_session_id, audience, current, challenge_id, principal_id), + ).fetchone() + if row is None or not row[12] or row[4] != row[11] or not row[13]: + raise PermissionError("Owner challenge authority is unavailable") + if row[4] < 1 or not set(filter(None, row[3].split(","))) <= set( + filter(None, row[10].split(",")) + ): + raise PermissionError("Owner grants no longer cover the challenge") + if row[6] in {"authorized", "rejected"}: + if row[7] == decision_digest: + return { + "challenge_id": challenge_id, + "status": row[6], + "decision": row[8], + "decision_digest": row[7], + } + raise ValueError("Owner challenge already has a different decision") + if row[6] != "issued" or row[5] <= current: + raise PermissionError("Owner challenge is expired or no longer issuable") + if (row[0], row[1], row[2]) != (proposal_revision, payload_digest, diff_digest): + raise ValueError("Owner decision does not match the challenged content") + if not hmac.compare_digest( + hashlib.sha256( + connection.execute( + "SELECT diff_json FROM owner_challenges WHERE challenge_id=?", + (challenge_id,), + ) + .fetchone()[0] + .encode("utf-8") + ).hexdigest(), + diff_digest, + ): + raise ValueError("Owner challenge diff integrity check failed") + status = "authorized" if decision == "authorize" else "rejected" + connection.execute( + "UPDATE owner_challenges SET status=?, decision=?, rationale=?, " + "decided_at=?, decision_digest=?, owner_session_id=? " + "WHERE challenge_id=? AND status='issued'", + ( + status, + decision, + rationale.strip(), + current, + decision_digest, + owner_session_id, + challenge_id, + ), + ) + connection.execute( + "INSERT INTO owner_security_events VALUES (?, ?, ?, ?)", + (secrets.token_hex(16), principal_id, f"owner-challenge-{status}", current), + ) + return { + "challenge_id": challenge_id, + "status": status, + "decision": decision, + "decision_digest": decision_digest, + } + + def create_browser_session(self, bearer_token: str) -> BrowserSession: + principal = self.authenticate(bearer_token) + if ( + principal is None + or "agent" in principal.roles + or not principal.roles.intersection({"admin", "editor", "viewer"}) + ): + raise PermissionError("A human browser identity is required") + with self._connect() as connection: + return self._insert_browser_session(connection, principal.credential_id) + + @staticmethod + def _insert_browser_session( + connection: sqlite3.Connection, credential_id: str + ) -> BrowserSession: + session_token = "gws_" + secrets.token_urlsafe(32) + csrf_token = "gwc_" + secrets.token_urlsafe(32) + expires_at = int(datetime.now(UTC).timestamp()) + BROWSER_SESSION_TTL_SECONDS + active = connection.execute( + "SELECT 1 FROM credentials WHERE credential_id=? AND revoked=0", + (credential_id,), + ).fetchone() + if active is None: + raise PermissionError("Human browser credential is invalid or revoked") + connection.execute( + "INSERT INTO browser_sessions(session_digest, csrf_digest, credential_id, " + "expires_at, revoked) VALUES (?, ?, ?, ?, 0)", + ( + hashlib.sha256(session_token.encode("ascii")).hexdigest(), + hashlib.sha256(csrf_token.encode("ascii")).hexdigest(), + credential_id, + expires_at, + ), + ) + return BrowserSession(session_token, csrf_token, datetime.fromtimestamp(expires_at, UTC)) + + def authenticate_browser_session(self, session_token: str) -> Principal | None: + if len(session_token) > 128 or not session_token.startswith("gws_"): + return None + digest = hashlib.sha256(session_token.encode("ascii", errors="ignore")).hexdigest() + now = int(datetime.now(UTC).timestamp()) + with self._connect() as connection: + row = connection.execute( + "SELECT c.credential_id, c.principal_id, c.roles, c.project_ids " + "FROM browser_sessions s JOIN credentials c USING(credential_id) " + "WHERE s.session_digest=? AND s.revoked=0 AND s.expires_at>? AND c.revoked=0", + (digest, now), + ).fetchone() + if row is None: + return None + credential_id, principal_id, roles, project_ids = row + return Principal( + principal_id=principal_id, + credential_id=credential_id, + roles=frozenset(roles.split(",")), + project_ids=frozenset(filter(None, project_ids.split(","))), + auth_method="browser_session", + ) + + def restore_browser_session(self, session_token: str) -> BrowserSession | None: + if len(session_token) > 128 or not session_token.startswith("gws_"): + return None + session_digest = hashlib.sha256(session_token.encode("ascii", errors="ignore")).hexdigest() + # Derive a stable, one-way token from the private session secret. Keep the + # originally issued CSRF hash so existing tabs and pre-upgrade sessions work. + csrf_token = ( + "gwc_" + + hashlib.sha256( + b"gewu:browser-csrf:v1:" + session_token.encode("ascii", errors="ignore") + ).hexdigest() + ) + now = int(datetime.now(UTC).timestamp()) + with self._connect() as connection: + connection.execute( + "UPDATE browser_sessions SET restored_csrf_digest=? WHERE session_digest=? " + "AND revoked=0 AND expires_at>? AND EXISTS (SELECT 1 FROM credentials c " + "WHERE c.credential_id=browser_sessions.credential_id AND c.revoked=0 " + "AND c.roles NOT LIKE '%agent%')", + (hashlib.sha256(csrf_token.encode("ascii")).hexdigest(), session_digest, now), + ) + row = connection.execute( + "SELECT s.expires_at FROM browser_sessions s " + "JOIN credentials c USING(credential_id) " + "WHERE s.session_digest=? AND s.revoked=0 AND s.expires_at>? AND c.revoked=0 " + "AND c.roles NOT LIKE '%agent%'", + (session_digest, now), + ).fetchone() + if row is None: + return None + return BrowserSession(session_token, csrf_token, datetime.fromtimestamp(row[0], UTC)) + + def browser_csrf_valid(self, session_token: str, csrf_token: str) -> bool: + if ( + len(session_token) > 128 + or len(csrf_token) > 128 + or not session_token.startswith("gws_") + or not csrf_token.startswith("gwc_") + ): + return False + session_digest = hashlib.sha256(session_token.encode("ascii", errors="ignore")).hexdigest() + expected_digest = hashlib.sha256(csrf_token.encode("ascii", errors="ignore")).hexdigest() + now = int(datetime.now(UTC).timestamp()) + with self._connect() as connection: + row = connection.execute( + "SELECT s.csrf_digest, s.restored_csrf_digest FROM browser_sessions s " + "JOIN credentials c USING(credential_id) " + "WHERE s.session_digest=? AND s.revoked=0 AND s.expires_at>? AND c.revoked=0", + (session_digest, now), + ).fetchone() + return row is not None and any( + value is not None and hmac.compare_digest(str(value), expected_digest) for value in row + ) + + def revoke_browser_session(self, session_token: str) -> bool: + if len(session_token) > 128 or not session_token.startswith("gws_"): + return False + digest = hashlib.sha256(session_token.encode("ascii", errors="ignore")).hexdigest() + with self._connect() as connection: + cursor = connection.execute( + "UPDATE browser_sessions SET revoked=1 WHERE session_digest=? AND revoked=0", + (digest,), + ) + return cursor.rowcount == 1 + + def revoke(self, credential_id: str, *, actor: str | None = None) -> bool: + with self._authority_lock(): + with self._connect() as connection: + cursor = connection.execute( + "UPDATE credentials SET revoked=1 WHERE credential_id=? AND revoked=0", + (credential_id,), + ) + if cursor.rowcount == 1: + # Disabling a password account through the legacy key list also + # revokes its agents, matching the dedicated disable-account route. + account = connection.execute( + "SELECT c.principal_id FROM user_accounts u JOIN credentials c " + "USING(credential_id) WHERE u.credential_id=?", + (credential_id,), + ).fetchone() + if account: + connection.execute( + "UPDATE credentials SET revoked=1 WHERE owner_principal_id=?", + (account[0],), + ) + if cursor.rowcount == 1 and actor is not None: + self._record_admin_event(connection, actor, "credential.revoked", credential_id) + return cursor.rowcount == 1 + + def rotate(self, credential_id: str, new_credential_id: str) -> str: + """Atomically replace one active key while retaining its principal and grants.""" + + if not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9._:-]{0,127}", new_credential_id): + raise ValueError("Credential identity is invalid") + token = "gwu_" + secrets.token_urlsafe(32) + digest = hashlib.sha256(token.encode("ascii")).hexdigest() + with self._authority_lock(): + with self._connect() as connection: + row = connection.execute( + "SELECT principal_id, roles, project_ids, owner_principal_id FROM credentials " + "WHERE credential_id=? AND revoked=0", + (credential_id,), + ).fetchone() + if row is None: + raise KeyError("Active credential was not found") + connection.execute( + "INSERT INTO credentials(credential_id, principal_id, token_digest, roles, " + "project_ids, " + "revoked) VALUES (?, ?, ?, ?, ?, 0)", + (new_credential_id, row[0], digest, row[1], row[2]), + ) + connection.execute( + "UPDATE credentials SET owner_principal_id=? WHERE credential_id=?", + (row[3], new_credential_id), + ) + connection.execute( + "UPDATE credentials SET revoked=1 WHERE credential_id=?", (credential_id,) + ) + self.database.chmod(0o600) + return token + + def is_current( + self, + principal_id: str, + credential_id: str, + *, + roles: frozenset[str] | None = None, + project_ids: frozenset[str] | None = None, + ) -> bool: + with self._connect() as connection: + row = connection.execute( + "SELECT roles, project_ids FROM credentials " + "WHERE principal_id=? AND credential_id=? AND revoked=0", + (principal_id, credential_id), + ).fetchone() + if row is None: + return False + current_roles = frozenset(filter(None, row[0].split(","))) + current_projects = frozenset(filter(None, row[1].split(","))) + return (roles is None or roles == current_roles) and ( + project_ids is None or project_ids == current_projects + ) + + def _authority_lock(self) -> AbstractContextManager[Any]: + if self.authority_lock_path is None: + return nullcontext() + return portalocker.Lock(self.authority_lock_path, mode="a", timeout=30) diff --git a/src/gewu/adapters/sqlite_search.py b/src/gewu/adapters/sqlite_search.py new file mode 100644 index 0000000..088c8a2 --- /dev/null +++ b/src/gewu/adapters/sqlite_search.py @@ -0,0 +1,572 @@ +"""Rebuildable SQLite FTS5 index over accepted Git nodes.""" + +from __future__ import annotations + +import hashlib +import re +import sqlite3 +from collections.abc import Callable +from contextlib import closing +from datetime import UTC, date, datetime +from pathlib import Path + +import yaml +from pydantic import ValidationError + +from gewu.application.paths import reject_symlink_components +from gewu.application.ports import CanonicalRepository +from gewu.application.search import ( + IndexedNode, + SearchEdgeHit, + SearchEdgeResult, + SearchIndexResult, + SourceIndexResult, +) +from gewu.application.serialization import safe_yaml_record +from gewu.domain.contracts import Confidence, KnowledgeNode, SourceRevisionRecord, TypedEdge + +MAX_INDEX_NODES = 100_000 +MAX_INDEX_EDGES = 500_000 +MAX_INDEX_RECORD_BYTES = 1_048_576 +INDEX_SCHEMA_VERSION = 4 +_WORD = re.compile(r"[^\W_]+", flags=re.UNICODE) + + +class SearchIndexError(RuntimeError): + """Safe failure while rebuilding or querying the derived index.""" + + +class SQLiteSearchIndex: + """SQLite FTS5 index that can be deleted and reconstructed from Git.""" + + def __init__(self, path: Path, repository: CanonicalRepository) -> None: + self.path = path.expanduser().absolute() + reject_symlink_components(self.path) + self.path.parent.mkdir(parents=True, exist_ok=True, mode=0o700) + self.path.parent.chmod(0o700) + if self.path.is_symlink(): + raise RuntimeError("Search index path must not be a symbolic link") + self.repository = repository + self._vector_extension_loader: Callable[[sqlite3.Connection], None] | None = None + self._vector_generation_builder: ( + Callable[[sqlite3.Connection, str, tuple[KnowledgeNode, ...]], None] | None + ) = None + self._vector_generation_is_current: Callable[[str], bool] | None = None + self._initialize() + + def configure_vector_generation( + self, + *, + extension_loader: Callable[[sqlite3.Connection], None], + builder: Callable[[sqlite3.Connection, str, tuple[KnowledgeNode, ...]], None], + is_current: Callable[[str], bool], + ) -> None: + """Install the optional vector publisher before serving reads.""" + + self._vector_extension_loader = extension_loader + self._vector_generation_builder = builder + self._vector_generation_is_current = is_current + + def _connect(self) -> sqlite3.Connection: + connection = sqlite3.connect(self.path, timeout=5, isolation_level=None) + connection.execute("PRAGMA foreign_keys=ON") + connection.execute("PRAGMA busy_timeout=5000") + connection.execute("PRAGMA synchronous=NORMAL") + if self._vector_extension_loader is not None: + try: + self._vector_extension_loader(connection) + except (OSError, RuntimeError, sqlite3.Error) as error: + connection.close() + raise SearchIndexError("SQLite vector extension could not be loaded") from error + return connection + + def _initialize(self) -> None: + with closing(self._connect()) as connection: + connection.execute("PRAGMA journal_mode=WAL") + connection.execute( + "CREATE TABLE IF NOT EXISTS index_state (" + "singleton INTEGER PRIMARY KEY CHECK(singleton=1), " + "schema_version INTEGER NOT NULL, revision TEXT NOT NULL)" + ) + connection.execute( + "CREATE TABLE IF NOT EXISTS indexed_nodes (" + "node_id TEXT PRIMARY KEY, project_id TEXT NOT NULL, node_json TEXT NOT NULL)" + ) + connection.execute( + "CREATE INDEX IF NOT EXISTS indexed_nodes_project " + "ON indexed_nodes(project_id, node_id)" + ) + connection.execute( + "CREATE TABLE IF NOT EXISTS indexed_sources (" + "source_revision_id TEXT PRIMARY KEY, source_json TEXT NOT NULL)" + ) + connection.execute( + "CREATE TABLE IF NOT EXISTS indexed_source_grants (" + "source_revision_id TEXT NOT NULL, project_id TEXT NOT NULL, " + "PRIMARY KEY(source_revision_id, project_id))" + ) + connection.execute( + "CREATE INDEX IF NOT EXISTS indexed_source_grants_project " + "ON indexed_source_grants(project_id, source_revision_id)" + ) + connection.execute( + "CREATE TABLE IF NOT EXISTS indexed_edges (" + "edge_id TEXT PRIMARY KEY, source_kind TEXT NOT NULL, source_id TEXT NOT NULL, " + "target_kind TEXT NOT NULL, target_id TEXT NOT NULL, edge_type TEXT NOT NULL, " + "edge_json TEXT NOT NULL)" + ) + connection.execute( + "CREATE INDEX IF NOT EXISTS indexed_edges_source " + "ON indexed_edges(source_kind, source_id, edge_type, edge_id)" + ) + connection.execute( + "CREATE INDEX IF NOT EXISTS indexed_edges_target " + "ON indexed_edges(target_kind, target_id, edge_type, edge_id)" + ) + try: + connection.execute( + "CREATE VIRTUAL TABLE IF NOT EXISTS node_fts USING fts5(" + "node_id UNINDEXED, project_id UNINDEXED, title, summary, claims, tags, " + "tokenize='unicode61 remove_diacritics 2')" + ) + except sqlite3.OperationalError as error: + raise SearchIndexError("SQLite FTS5 is unavailable in this runtime") from error + self.path.chmod(0o600) + + def _indexed_revision(self) -> str | None: + with closing(self._connect()) as connection: + row = connection.execute( + "SELECT revision FROM index_state WHERE singleton=1 AND schema_version=?", + (INDEX_SCHEMA_VERSION,), + ).fetchone() + return str(row[0]) if row is not None else None + + def diagnostic_status(self) -> tuple[str | None, int, bool]: + try: + return self._indexed_revision(), INDEX_SCHEMA_VERSION, True + except sqlite3.Error: + return None, INDEX_SCHEMA_VERSION, False + + @staticmethod + def _node_paths(repository: CanonicalRepository, revision: str) -> tuple[str, ...]: + return tuple( + path + for path in repository.list_files(revision, "projects") + if "/nodes/" in path and path.endswith(".yaml") + ) + + def _load_nodes(self, revision: str) -> tuple[KnowledgeNode, ...]: + nodes: list[KnowledgeNode] = [] + seen: set[str] = set() + for path in self._node_paths(self.repository, revision): + raw = self.repository.read_file(revision, path) + if raw is None or len(raw) > MAX_INDEX_RECORD_BYTES: + raise SearchIndexError("Canonical search record is unavailable or too large") + try: + node = safe_yaml_record(raw, KnowledgeNode) + except (ValueError, yaml.YAMLError, ValidationError) as error: + raise SearchIndexError("Canonical search record failed validation") from error + node_key = hashlib.sha256(node.node_id.encode("utf-8")).hexdigest() + project_key = hashlib.sha256(node.project_id.encode("utf-8")).hexdigest() + if node.node_id in seen or path != f"projects/{project_key}/nodes/{node_key}.yaml": + raise SearchIndexError("Canonical search identity does not match its path") + seen.add(node.node_id) + nodes.append(node) + if len(nodes) > MAX_INDEX_NODES: + raise SearchIndexError("Canonical search index exceeds its node limit") + return tuple(nodes) + + def _load_edges(self, revision: str) -> tuple[TypedEdge, ...]: + edges: list[TypedEdge] = [] + seen: set[str] = set() + for path in self.repository.list_files(revision, "edges"): + if not path.endswith(".yaml"): + continue + raw = self.repository.read_file(revision, path) + if raw is None or len(raw) > MAX_INDEX_RECORD_BYTES: + raise SearchIndexError("Canonical relationship is unavailable or too large") + try: + edge = safe_yaml_record(raw, TypedEdge) + except (ValueError, yaml.YAMLError, ValidationError) as error: + raise SearchIndexError("Canonical relationship failed validation") from error + edge_key = hashlib.sha256(edge.edge_id.encode("utf-8")).hexdigest() + if edge.edge_id in seen or path != f"edges/{edge_key}.yaml": + raise SearchIndexError("Canonical relationship identity does not match its path") + seen.add(edge.edge_id) + edges.append(edge) + if len(edges) > MAX_INDEX_EDGES: + raise SearchIndexError("Canonical relationship index exceeds its edge limit") + return tuple(edges) + + def _load_sources(self, revision: str) -> tuple[SourceRevisionRecord, ...]: + sources: list[SourceRevisionRecord] = [] + seen: set[str] = set() + for path in self.repository.list_files(revision, "sources"): + if not path.endswith(".yaml"): + continue + raw = self.repository.read_file(revision, path) + if raw is None or len(raw) > MAX_INDEX_RECORD_BYTES: + raise SearchIndexError("Canonical source record is unavailable or too large") + try: + source = safe_yaml_record(raw, SourceRevisionRecord) + except (ValueError, yaml.YAMLError, ValidationError) as error: + raise SearchIndexError("Canonical source record failed validation") from error + source_key = hashlib.sha256(source.source_id.encode("utf-8")).hexdigest() + revision_key = hashlib.sha256(source.revision_id.encode("utf-8")).hexdigest() + if source.revision_id in seen or path != f"sources/{source_key}/{revision_key}.yaml": + raise SearchIndexError("Canonical source identity does not match its path") + seen.add(source.revision_id) + sources.append(source) + return tuple(sources) + + def _rebuild_if_needed(self) -> str: + revision = self.repository.head() + indexed_revision = self._indexed_revision() + vector_is_current = ( + self._vector_generation_is_current(revision) + if self._vector_generation_is_current is not None + else True + ) + if indexed_revision == revision and vector_is_current: + return revision + nodes = self._load_nodes(revision) + edges = self._load_edges(revision) + sources = self._load_sources(revision) + try: + with closing(self._connect()) as connection: + connection.execute("BEGIN IMMEDIATE") + connection.execute("DELETE FROM node_fts") + connection.execute("DELETE FROM indexed_nodes") + connection.execute("DELETE FROM indexed_edges") + connection.execute("DELETE FROM indexed_sources") + connection.execute("DELETE FROM indexed_source_grants") + for node in nodes: + node_json = node.model_dump_json() + claims = "\n".join(claim.text for claim in node.evidence_claims) + connection.execute( + "INSERT INTO indexed_nodes(node_id, project_id, node_json) " + "VALUES (?, ?, ?)", + (node.node_id, node.project_id, node_json), + ) + connection.execute( + "INSERT INTO node_fts(node_id, project_id, title, summary, claims, tags) " + "VALUES (?, ?, ?, ?, ?, ?)", + ( + node.node_id, + node.project_id, + node.title, + node.summary, + claims, + " ".join((*node.tags, *node.aliases)), + ), + ) + for edge in edges: + connection.execute( + "INSERT INTO indexed_edges(edge_id, source_kind, source_id, target_kind, " + "target_id, edge_type, edge_json) VALUES (?, ?, ?, ?, ?, ?, ?)", + ( + edge.edge_id, + edge.source.kind, + edge.source.id, + edge.target.kind, + edge.target.id, + edge.edge_type, + edge.model_dump_json(), + ), + ) + for source in sources: + connection.execute( + "INSERT INTO indexed_sources(source_revision_id, source_json) " + "VALUES (?, ?)", + (source.revision_id, source.model_dump_json()), + ) + connection.executemany( + "INSERT INTO indexed_source_grants(source_revision_id, project_id) " + "VALUES (?, ?)", + ((source.revision_id, project_id) for project_id in source.ingested_for), + ) + if self._vector_generation_builder is not None: + self._vector_generation_builder(connection, revision, nodes) + connection.execute( + "INSERT INTO index_state(singleton, schema_version, revision) " + "VALUES (1, ?, ?) " + "ON CONFLICT(singleton) DO UPDATE SET schema_version=excluded.schema_version, " + "revision=excluded.revision", + (INDEX_SCHEMA_VERSION, revision), + ) + connection.commit() + except (sqlite3.Error, OSError) as error: + raise SearchIndexError("Derived search index could not be rebuilt") from error + self.path.chmod(0o600) + return revision + + def canonical_nodes(self) -> tuple[str, tuple[KnowledgeNode, ...]]: + """Return the complete validated node set at the current indexed revision.""" + + revision = self._rebuild_if_needed() + return revision, self._load_nodes(revision) + + @staticmethod + def _match_expression(query: str) -> str: + terms = _WORD.findall(query) + return " AND ".join('"' + term.replace('"', '""') + '"' for term in terms) + + def search( + self, + *, + query: str, + project_ids: tuple[str, ...], + include_shared: bool, + limit: int, + node_types: tuple[str, ...] = (), + tags: tuple[str, ...] = (), + audience: tuple[str, ...] = (), + use_for: tuple[str, ...] = (), + domain: tuple[str, ...] = (), + confidences: tuple[Confidence, ...] = (), + as_of_from: date | None = None, + as_of_to: date | None = None, + stale_only: bool = False, + candidate_node_ids: tuple[str, ...] | None = None, + offset: int = 0, + ) -> SearchIndexResult: + revision = self._rebuild_if_needed() + match = self._match_expression(query) + project_scope = tuple(sorted(set(project_ids) | ({"shared"} if include_shared else set()))) + if not project_scope: + return SearchIndexResult(revision, ()) + placeholders = ", ".join("?" for _ in project_scope) + filters: list[str] = [] + filter_parameters: list[str] = [] + if node_types: + filters.append( + "json_extract(indexed_nodes.node_json, '$.node_type') IN (" + + ", ".join("?" for _ in node_types) + + ")" + ) + filter_parameters.extend(node_types) + for field, values in ( + ("tags", tags), + ("audience", audience), + ("use_for", use_for), + ("domain", domain), + ): + if values: + value_slots = ", ".join("?" for _ in values) + filters.append( + "EXISTS (SELECT 1 FROM json_each(indexed_nodes.node_json, '$." + + field + + "') AS facet WHERE facet.value IN (" + + value_slots + + "))" + ) + filter_parameters.extend(values) + if confidences: + filters.append( + "json_extract(indexed_nodes.node_json, '$.confidence') IN (" + + ", ".join("?" for _ in confidences) + + ")" + ) + filter_parameters.extend(item.value for item in confidences) + if as_of_from is not None: + filters.append("substr(json_extract(indexed_nodes.node_json, '$.as_of'), 1, 10) >= ?") + filter_parameters.append(as_of_from.isoformat()) + if as_of_to is not None: + filters.append("substr(json_extract(indexed_nodes.node_json, '$.as_of'), 1, 10) <= ?") + filter_parameters.append(as_of_to.isoformat()) + if stale_only: + filters.append("json_extract(indexed_nodes.node_json, '$.stale_after') <= ?") + filter_parameters.append(datetime.now(UTC).date().isoformat() + "T00:00:00Z") + if candidate_node_ids is not None: + if not candidate_node_ids: + return SearchIndexResult(revision, ()) + filters.append( + "indexed_nodes.node_id IN (" + ", ".join("?" for _ in candidate_node_ids) + ")" + ) + filter_parameters.extend(candidate_node_ids) + extra_filter = " AND " + " AND ".join(filters) if filters else "" + if match: + statement = ( + "SELECT indexed_nodes.node_json, bm25(node_fts) AS score " + "FROM node_fts JOIN indexed_nodes USING(node_id) " + "WHERE node_fts MATCH ? AND indexed_nodes.project_id IN (" + + placeholders + + ")" + + extra_filter + + " ORDER BY score ASC, indexed_nodes.node_id ASC LIMIT ? OFFSET ?" + ) + parameters: tuple[object, ...] = ( + match, + *project_scope, + *filter_parameters, + limit + 1, + offset, + ) + else: + statement = ( + "SELECT indexed_nodes.node_json, 0.0 AS score FROM indexed_nodes " + "WHERE indexed_nodes.project_id IN (" + + placeholders + + ")" + + extra_filter + + " ORDER BY indexed_nodes.node_id ASC LIMIT ? OFFSET ?" + ) + parameters = (*project_scope, *filter_parameters, limit + 1, offset) + try: + with closing(self._connect()) as connection: + rows = connection.execute(statement, parameters).fetchall() + except sqlite3.Error as error: + raise SearchIndexError("Search query could not be evaluated") from error + results: list[IndexedNode] = [] + has_more = len(rows) > limit + for node_json, score in rows[:limit]: + try: + node = KnowledgeNode.model_validate_json(node_json, strict=True) + except ValidationError as error: + raise SearchIndexError("Indexed canonical search record is invalid") from error + results.append(IndexedNode(node=node, score=float(score))) + return SearchIndexResult(revision, tuple(results), has_more) + + def neighbors( + self, + *, + node_id: str, + edge_types: tuple[str, ...], + project_ids: tuple[str, ...], + include_shared: bool, + limit: int, + offset: int = 0, + ) -> SearchEdgeResult: + self._rebuild_if_needed() + project_scope = tuple(sorted(set(project_ids) | ({"shared"} if include_shared else set()))) + if not project_scope: + raise SearchIndexError("Neighbor scope is empty") + scope_placeholders = ", ".join("?" for _ in project_scope) + + def eligible(kind_column: str, id_column: str) -> str: + return ( + f"(({kind_column}='node' AND EXISTS (SELECT 1 FROM indexed_nodes eligible_node " + f"WHERE eligible_node.node_id=e.{id_column} AND eligible_node.project_id IN " + f"({scope_placeholders}))) OR " + f"({kind_column}='project' AND e.{id_column} IN ({scope_placeholders})) OR " + f"({kind_column}='source' AND EXISTS (SELECT 1 FROM indexed_source_grants grant " + f"WHERE grant.source_revision_id=e.{id_column} AND grant.project_id IN " + f"({scope_placeholders}))))" + ) + + target_eligible = eligible("e.target_kind", "target_id") + source_eligible = eligible("e.source_kind", "source_id") + edge_filter = "" + edge_parameters: tuple[str, ...] = () + if edge_types: + edge_filter = " AND edge_type IN (" + ", ".join("?" for _ in edge_types) + ")" + edge_parameters = edge_types + statement = ( + "SELECT e.edge_json FROM indexed_edges e WHERE " + f"((e.source_kind='node' AND e.source_id=? AND {target_eligible}) OR " + f"(e.target_kind='node' AND e.target_id=? AND {source_eligible}))" + + edge_filter + + " ORDER BY e.edge_id LIMIT ? OFFSET ?" + ) + scope_parameters = (*project_scope, *project_scope, *project_scope) + parameters = ( + node_id, + *scope_parameters, + node_id, + *scope_parameters, + *edge_parameters, + limit + 1, + offset, + ) + try: + with closing(self._connect()) as connection: + connection.execute("BEGIN") + state = connection.execute( + "SELECT revision FROM index_state WHERE singleton=1 AND schema_version=?", + (INDEX_SCHEMA_VERSION,), + ).fetchone() + if state is None: + raise SearchIndexError("Search index revision is unavailable") + revision = str(state[0]) + origin_row = connection.execute( + "SELECT node_json FROM indexed_nodes WHERE node_id=?", (node_id,) + ).fetchone() + origin = ( + KnowledgeNode.model_validate_json(origin_row[0], strict=True) + if origin_row is not None + else None + ) + rows = connection.execute(statement, parameters).fetchall() + hits: list[SearchEdgeHit] = [] + for (edge_json,) in rows[:limit]: + edge = TypedEdge.model_validate_json(edge_json, strict=True) + if edge.source.kind == "node" and edge.source.id == node_id: + endpoint = edge.target + elif edge.target.kind == "node" and edge.target.id == node_id: + endpoint = edge.source + else: + continue + neighbor_node = None + neighbor_source = None + if endpoint.kind == "node": + neighbor_row = connection.execute( + "SELECT node_json FROM indexed_nodes WHERE node_id=?", (endpoint.id,) + ).fetchone() + if neighbor_row is not None: + neighbor_node = KnowledgeNode.model_validate_json( + neighbor_row[0], strict=True + ) + elif endpoint.kind == "source": + source_row = connection.execute( + "SELECT source_json FROM indexed_sources WHERE source_revision_id=?", + (endpoint.id,), + ).fetchone() + if source_row is not None: + neighbor_source = SourceRevisionRecord.model_validate_json( + source_row[0], strict=True + ) + hits.append(SearchEdgeHit(edge, endpoint, neighbor_node, neighbor_source)) + connection.commit() + except (sqlite3.Error, ValidationError) as error: + raise SearchIndexError("Neighbor query could not be evaluated") from error + return SearchEdgeResult(revision, origin, tuple(hits), len(rows) > limit) + + def list_sources( + self, + *, + project_ids: tuple[str, ...], + include_shared: bool, + limit: int, + offset: int = 0, + ) -> SourceIndexResult: + self._rebuild_if_needed() + project_scope = tuple(sorted(set(project_ids) | ({"shared"} if include_shared else set()))) + if not project_scope: + raise SearchIndexError("Source scope is empty") + placeholders = ", ".join("?" for _ in project_scope) + statement = ( + "SELECT DISTINCT s.source_json FROM indexed_sources s " + "JOIN indexed_source_grants g USING(source_revision_id) " + f"WHERE g.project_id IN ({placeholders}) " + "ORDER BY json_extract(s.source_json, '$.captured_at') DESC, " + "s.source_revision_id ASC LIMIT ? OFFSET ?" + ) + try: + with closing(self._connect()) as connection: + connection.execute("BEGIN") + state = connection.execute( + "SELECT revision FROM index_state WHERE singleton=1 AND schema_version=?", + (INDEX_SCHEMA_VERSION,), + ).fetchone() + if state is None: + raise SearchIndexError("Search index revision is unavailable") + revision = str(state[0]) + rows = connection.execute(statement, (*project_scope, limit + 1, offset)).fetchall() + sources = tuple( + SourceRevisionRecord.model_validate_json(source_json, strict=True) + for (source_json,) in rows[:limit] + ) + connection.commit() + except (sqlite3.Error, ValidationError) as error: + raise SearchIndexError("Source query could not be evaluated") from error + return SourceIndexResult(revision, sources, len(rows) > limit) diff --git a/src/gewu/adapters/sqlite_vectors.py b/src/gewu/adapters/sqlite_vectors.py new file mode 100644 index 0000000..3154373 --- /dev/null +++ b/src/gewu/adapters/sqlite_vectors.py @@ -0,0 +1,462 @@ +"""Scoped sqlite-vec storage with immutable generations and atomic activation.""" + +from __future__ import annotations + +import hashlib +import importlib +import math +import sqlite3 +import struct +from collections.abc import Iterable +from contextlib import closing +from pathlib import Path +from typing import Any + +from gewu.application.paths import reject_symlink_components +from gewu.application.ports import VectorGeneration, VectorHit + + +class VectorIndexError(RuntimeError): + """Safe vector-index operation failure.""" + + +class SQLiteVectorIndex: + """SQLite vector partitions isolate candidate search by authorized project.""" + + def __init__(self, path: Path) -> None: + self.path = path.expanduser().absolute() + reject_symlink_components(self.path) + self.path.parent.mkdir(parents=True, exist_ok=True, mode=0o700) + self.path.parent.chmod(0o700) + if self.path.is_symlink(): + raise VectorIndexError("Vector index path must not be a symbolic link") + try: + self._sqlite_vec = importlib.import_module("sqlite_vec") + except ImportError as error: + raise VectorIndexError("Install the optional retrieval dependencies") from error + self._initialize() + + def _connect(self) -> sqlite3.Connection: + connection = sqlite3.connect(self.path, timeout=5, isolation_level=None) + connection.execute("PRAGMA foreign_keys=ON") + connection.execute("PRAGMA busy_timeout=5000") + connection.execute("PRAGMA synchronous=NORMAL") + try: + self.load_extension(connection) + except VectorIndexError: + connection.close() + raise + return connection + + def load_extension(self, connection: sqlite3.Connection) -> None: + """Load the packaged extension for a caller-owned transaction connection.""" + + try: + connection.enable_load_extension(True) + self._sqlite_vec.load(connection) + except (AttributeError, sqlite3.Error, OSError) as error: + raise VectorIndexError("SQLite vector extension could not be loaded") from error + finally: + try: + connection.enable_load_extension(False) + except sqlite3.Error: + pass + + def _initialize(self) -> None: + try: + with closing(self._connect()) as connection: + connection.execute("PRAGMA journal_mode=WAL") + connection.executescript( + "CREATE TABLE IF NOT EXISTS vector_generations (" + "generation_id TEXT PRIMARY KEY, revision TEXT NOT NULL, " + "model_id TEXT NOT NULL, " + "artifact_sha256 TEXT NOT NULL, content_profile TEXT NOT NULL, " + "dimensions INTEGER NOT NULL, " + "node_count INTEGER NOT NULL);" + "CREATE TABLE IF NOT EXISTS vector_partitions (" + "generation_id TEXT NOT NULL REFERENCES vector_generations(generation_id) " + "ON DELETE CASCADE, project_id TEXT NOT NULL, table_name TEXT NOT NULL UNIQUE, " + "PRIMARY KEY(generation_id, project_id));" + "CREATE TABLE IF NOT EXISTS vector_node_ids (" + "generation_id TEXT NOT NULL, project_id TEXT NOT NULL, " + "vector_rowid INTEGER NOT NULL, " + "node_id TEXT NOT NULL, PRIMARY KEY(generation_id, project_id, vector_rowid), " + "UNIQUE(generation_id, project_id, node_id), " + "FOREIGN KEY(generation_id, project_id) " + "REFERENCES vector_partitions(generation_id, project_id) ON DELETE CASCADE);" + "CREATE TABLE IF NOT EXISTS active_vector_generation (" + "singleton INTEGER PRIMARY KEY CHECK(singleton=1), " + "generation_id TEXT NOT NULL REFERENCES vector_generations(generation_id));" + ) + generation_columns = { + str(row[1]) + for row in connection.execute("PRAGMA table_info(vector_generations)") + } + if "content_profile" not in generation_columns: + connection.execute( + "ALTER TABLE vector_generations ADD COLUMN content_profile " + "TEXT NOT NULL DEFAULT 'legacy-unqualified'" + ) + except sqlite3.Error as error: + raise VectorIndexError("Vector index schema could not be initialized") from error + self.path.chmod(0o600) + + @staticmethod + def _table_name(generation_id: str, project_id: str) -> str: + digest = hashlib.sha256(f"{generation_id}\0{project_id}".encode()).hexdigest() + return f"vec_{digest}" + + @staticmethod + def _vector_bytes(vector: tuple[float, ...], dimensions: int) -> bytes: + if len(vector) != dimensions or not all(math.isfinite(value) for value in vector): + raise VectorIndexError("Vector dimensions or values are invalid") + norm = math.sqrt(sum(value * value for value in vector)) + if not math.isclose(norm, 1.0, rel_tol=1e-4, abs_tol=1e-4): + raise VectorIndexError("Vectors must be normalized before indexing") + try: + return struct.pack(f"{dimensions}f", *vector) + except (OverflowError, struct.error) as error: + raise VectorIndexError("Vector cannot be stored") from error + + def build_generation( + self, + *, + generation_id: str, + revision: str, + model_id: str, + artifact_sha256: str, + dimensions: int, + vectors: Iterable[tuple[str, str, tuple[float, ...]]], + content_profile: str = "knowledge-node-v1", + ) -> None: + """Build a complete inactive generation in one transaction.""" + + try: + with closing(self._connect()) as connection: + connection.execute("BEGIN IMMEDIATE") + self.build_generation_in_transaction( + connection, + generation_id=generation_id, + revision=revision, + model_id=model_id, + artifact_sha256=artifact_sha256, + dimensions=dimensions, + vectors=vectors, + content_profile=content_profile, + ) + connection.commit() + except VectorIndexError: + raise + except (sqlite3.Error, OSError) as error: + raise VectorIndexError("Vector generation could not be built") from error + self.path.chmod(0o600) + + def build_generation_in_transaction( + self, + connection: sqlite3.Connection, + *, + generation_id: str, + revision: str, + model_id: str, + artifact_sha256: str, + dimensions: int, + vectors: Iterable[tuple[str, str, tuple[float, ...]]], + content_profile: str = "knowledge-node-v1", + ) -> None: + """Build into a caller's active transaction so FTS and vectors commit together.""" + + if not generation_id or not model_id or not content_profile or dimensions < 1: + raise VectorIndexError("Vector generation metadata is invalid") + if connection.execute( + "SELECT 1 FROM vector_generations WHERE generation_id=?", (generation_id,) + ).fetchone(): + raise VectorIndexError("Vector generation already exists") + connection.execute( + "INSERT INTO vector_generations(generation_id, revision, model_id, " + "artifact_sha256, content_profile, dimensions, node_count) " + "VALUES (?, ?, ?, ?, ?, ?, ?)", + (generation_id, revision, model_id, artifact_sha256, content_profile, dimensions, 0), + ) + current_project = "" + current_table = "" + rowid = 0 + node_ids: set[str] = set() + node_count = 0 + for project_id, node_id, vector in vectors: + if node_id in node_ids: + raise VectorIndexError("Vector generation contains duplicate node IDs") + node_ids.add(node_id) + if project_id < current_project: + raise VectorIndexError("Vector records must be ordered by project ID") + if project_id != current_project: + current_project = project_id + current_table = self._table_name(generation_id, project_id) + rowid = 0 + connection.execute( + "INSERT INTO vector_partitions VALUES (?, ?, ?)", + (generation_id, project_id, current_table), + ) + connection.execute( + f'CREATE VIRTUAL TABLE "{current_table}" USING vec0(' + f"embedding float[{dimensions}] distance_metric=cosine)" + ) + rowid += 1 + node_count += 1 + connection.execute( + f'INSERT INTO "{current_table}"(rowid, embedding) VALUES (?, ?)', + (rowid, self._vector_bytes(vector, dimensions)), + ) + connection.execute( + "INSERT INTO vector_node_ids VALUES (?, ?, ?, ?)", + (generation_id, project_id, rowid, node_id), + ) + connection.execute( + "UPDATE vector_generations SET node_count=? WHERE generation_id=?", + (node_count, generation_id), + ) + + def activate_in_transaction( + self, + connection: sqlite3.Connection, + generation_id: str, + *, + expected_active: str | None, + ) -> str | None: + """CAS the generation pointer on a caller-owned transaction.""" + + active_row = connection.execute( + "SELECT generation_id FROM active_vector_generation WHERE singleton=1" + ).fetchone() + active = str(active_row[0]) if active_row else None + if active != expected_active: + raise VectorIndexError("Active vector generation changed") + if ( + connection.execute( + "SELECT 1 FROM vector_generations WHERE generation_id=?", (generation_id,) + ).fetchone() + is None + ): + raise VectorIndexError("Vector generation is unavailable") + connection.execute( + "INSERT INTO active_vector_generation(singleton, generation_id) VALUES (1, ?) " + "ON CONFLICT(singleton) DO UPDATE SET generation_id=excluded.generation_id", + (generation_id,), + ) + return active + + def deactivate_in_transaction(self, connection: sqlite3.Connection) -> None: + connection.execute("DELETE FROM active_vector_generation WHERE singleton=1") + + @staticmethod + def active_generation_in_transaction( + connection: sqlite3.Connection, + ) -> VectorGeneration | None: + row = connection.execute( + "SELECT generation_id, revision, model_id, artifact_sha256, " + "content_profile, dimensions, node_count " + "FROM active_vector_generation JOIN vector_generations USING(generation_id) " + "WHERE singleton=1" + ).fetchone() + return SQLiteVectorIndex._generation_from_row(row) + + @staticmethod + def generation_in_transaction( + connection: sqlite3.Connection, generation_id: str + ) -> VectorGeneration | None: + row = connection.execute( + "SELECT generation_id, revision, model_id, artifact_sha256, " + "content_profile, dimensions, node_count " + "FROM vector_generations WHERE generation_id=?", + (generation_id,), + ).fetchone() + return SQLiteVectorIndex._generation_from_row(row) + + @staticmethod + def _generation_from_row(row: tuple[Any, ...] | None) -> VectorGeneration | None: + return ( + VectorGeneration( + generation_id=str(row[0]), + revision=str(row[1]), + model_id=str(row[2]), + artifact_sha256=str(row[3]), + content_profile=str(row[4]), + dimensions=int(row[5]), + node_count=int(row[6]), + ) + if row + else None + ) + + def activate(self, generation_id: str, *, expected_active: str | None) -> str | None: + """Switch the generation pointer with compare-and-swap semantics.""" + + try: + with closing(self._connect()) as connection: + connection.execute("BEGIN IMMEDIATE") + active_row = connection.execute( + "SELECT generation_id FROM active_vector_generation WHERE singleton=1" + ).fetchone() + active = str(active_row[0]) if active_row else None + if active != expected_active: + raise VectorIndexError("Active vector generation changed") + if ( + connection.execute( + "SELECT 1 FROM vector_generations WHERE generation_id=?", (generation_id,) + ).fetchone() + is None + ): + raise VectorIndexError("Vector generation is unavailable") + connection.execute( + "INSERT INTO active_vector_generation(singleton, generation_id) VALUES (1, ?) " + "ON CONFLICT(singleton) DO UPDATE SET generation_id=excluded.generation_id", + (generation_id,), + ) + connection.commit() + return active + except sqlite3.Error as error: + raise VectorIndexError("Vector generation could not be activated") from error + + def active_generation(self) -> VectorGeneration | None: + try: + with closing(self._connect()) as connection: + row = connection.execute( + "SELECT generation_id, revision, model_id, artifact_sha256, " + "content_profile, dimensions, node_count " + "FROM active_vector_generation JOIN vector_generations USING(generation_id) " + "WHERE singleton=1" + ).fetchone() + except sqlite3.Error as error: + raise VectorIndexError("Active vector generation could not be read") from error + return ( + VectorGeneration( + generation_id=str(row[0]), + revision=str(row[1]), + model_id=str(row[2]), + artifact_sha256=str(row[3]), + content_profile=str(row[4]), + dimensions=int(row[5]), + node_count=int(row[6]), + ) + if row + else None + ) + + def generation(self, generation_id: str) -> VectorGeneration | None: + try: + with closing(self._connect()) as connection: + row = connection.execute( + "SELECT generation_id, revision, model_id, artifact_sha256, " + "content_profile, dimensions, node_count " + "FROM vector_generations WHERE generation_id=?", + (generation_id,), + ).fetchone() + except sqlite3.Error as error: + raise VectorIndexError("Vector generation metadata could not be read") from error + return ( + VectorGeneration( + generation_id=str(row[0]), + revision=str(row[1]), + model_id=str(row[2]), + artifact_sha256=str(row[3]), + content_profile=str(row[4]), + dimensions=int(row[5]), + node_count=int(row[6]), + ) + if row + else None + ) + + def search( + self, + *, + query: tuple[float, ...], + project_ids: tuple[str, ...], + include_shared: bool, + limit: int, + expected_generation_id: str | None = None, + ) -> tuple[VectorHit, ...]: + if not project_ids or limit < 1: + return () + if limit > 100: + raise VectorIndexError("Vector candidate limit exceeds the configured maximum") + try: + with closing(self._connect()) as connection: + generation = connection.execute( + "SELECT generation_id, dimensions FROM active_vector_generation " + "JOIN vector_generations USING(generation_id) WHERE singleton=1" + ).fetchone() + if generation is None: + return () + generation_id, dimensions = str(generation[0]), int(generation[1]) + if expected_generation_id is not None and generation_id != expected_generation_id: + raise VectorIndexError("Active vector generation changed during search") + query_bytes = self._vector_bytes(query, dimensions) + scope = tuple( + dict.fromkeys((*project_ids, *(("shared",) if include_shared else ()))) + ) + hits: list[VectorHit] = [] + for project_id in scope: + partition = connection.execute( + "SELECT table_name FROM vector_partitions " + "WHERE generation_id=? AND project_id=?", + (generation_id, project_id), + ).fetchone() + if partition is None: + continue + table_name = str(partition[0]) + if table_name != self._table_name(generation_id, project_id): + raise VectorIndexError("Vector partition identity is invalid") + rows = connection.execute( + f'SELECT rowid, distance FROM "{table_name}" ' + "WHERE embedding MATCH ? AND k = ? ORDER BY distance", + (query_bytes, limit), + ).fetchall() + rowids = [int(row[0]) for row in rows] + if not rowids: + continue + marks = ", ".join("?" for _ in rowids) + identifiers = connection.execute( + "SELECT vector_rowid, node_id FROM vector_node_ids " + "WHERE generation_id=? AND project_id=? AND vector_rowid IN (" + + marks + + ")", + (generation_id, project_id, *rowids), + ).fetchall() + id_by_rowid = {int(rowid): str(node_id) for rowid, node_id in identifiers} + hits.extend( + VectorHit(id_by_rowid[int(rowid)], project_id, float(distance)) + for rowid, distance in rows + if int(rowid) in id_by_rowid + ) + except sqlite3.Error as error: + raise VectorIndexError("Vector query could not be evaluated") from error + return tuple( + sorted(hits, key=lambda item: (item.distance, item.project_id, item.node_id))[:limit] + ) + + def delete_generation(self, generation_id: str) -> None: + """Remove an inactive generation; an active generation cannot be deleted.""" + + try: + with closing(self._connect()) as connection: + connection.execute("BEGIN IMMEDIATE") + active = connection.execute( + "SELECT 1 FROM active_vector_generation WHERE generation_id=?", + (generation_id,), + ).fetchone() + if active: + raise VectorIndexError("Active vector generation cannot be deleted") + tables = connection.execute( + "SELECT project_id, table_name FROM vector_partitions WHERE generation_id=?", + (generation_id,), + ).fetchall() + for project_id, table_name in tables: + if str(table_name) != self._table_name(generation_id, str(project_id)): + raise VectorIndexError("Vector partition identity is invalid") + connection.execute(f'DROP TABLE "{table_name}"') + connection.execute( + "DELETE FROM vector_generations WHERE generation_id=?", (generation_id,) + ) + connection.commit() + except sqlite3.Error as error: + raise VectorIndexError("Vector generation could not be deleted") from error diff --git a/src/gewu/adapters/text_extractor.py b/src/gewu/adapters/text_extractor.py new file mode 100644 index 0000000..1088164 --- /dev/null +++ b/src/gewu/adapters/text_extractor.py @@ -0,0 +1,276 @@ +"""Versioned UTF-8 HTML/text/Markdown extraction with code-point location maps.""" + +from __future__ import annotations + +import hashlib +import re +import unicodedata +from html.parser import HTMLParser +from typing import Final, Literal + +import regex + +from gewu.domain.contracts import ExtractionBlock, ExtractionRecord +from gewu.domain.validation import canonical_json, extraction_digest + +MAX_BLOCK_CODEPOINTS = 80_000 +MAX_SOURCE_BYTES = 50 * 1024 * 1024 +PARSER_NAME = "gewu-text-html-markdown" +PARSER_VERSION = "0.3.0" +NORMALIZATION_PROFILE: Final[Literal["unicode-nfc-lf-v1"]] = "unicode-nfc-lf-v1" +PARSER_CONFIG = { + "max_block_codepoints": MAX_BLOCK_CODEPOINTS, + "media_types": [ + "text/html", + "text/markdown", + "text/plain", + "text/vtt", + "application/x-subrip", + ], + "normalization": NORMALIZATION_PROFILE, + "unicode_database_version": unicodedata.unidata_version, + "regex_version": regex.__version__, + "paragraph_split": "preserve-whole-text-in-bounded-blocks", + "transcript_split": "blank-separated-cues-with-exact-millisecond-ranges-v1", +} +PARSER_CONFIG_SHA256 = hashlib.sha256(canonical_json(PARSER_CONFIG)).hexdigest() + + +class TextExtractionError(ValueError): + """Unsupported or invalid text input.""" + + +class TextMarkdownExtractor: + """Extract bounded blocks while preserving offsets into original decoded text.""" + + def extract(self, source_revision_id: str, content: bytes, media_type: str) -> ExtractionRecord: + supported_media = { + "text/html", + "text/markdown", + "text/plain", + "text/vtt", + "application/x-subrip", + } + if media_type not in supported_media: + raise TextExtractionError( + "Text, Markdown, HTML and timestamped transcripts are supported" + ) + if not content or len(content) > MAX_SOURCE_BYTES: + raise TextExtractionError("Source size is outside the supported limit") + try: + original = content.decode("utf-8", errors="strict") + except UnicodeDecodeError as error: + raise TextExtractionError("Text artifacts must use UTF-8") from error + original = original.replace("\r\n", "\n").replace("\r", "\n") + if media_type == "text/html": + original = _html_to_text(original) + if not original: + raise TextExtractionError("HTML document contains no visible text") + + if media_type in {"text/vtt", "application/x-subrip"}: + blocks = tuple( + self._block(ordinal, text, time_start_ms, time_end_ms) + for ordinal, text, time_start_ms, time_end_ms in self._split_transcript(original) + ) + else: + blocks = tuple(self._block(ordinal, text) for ordinal, text in self._split(original)) + candidate = ExtractionRecord( + extraction_id="0" * 64, + source_revision_id=source_revision_id, + parser_name=PARSER_NAME, + parser_version=PARSER_VERSION, + parser_config_sha256=PARSER_CONFIG_SHA256, + normalization_profile=NORMALIZATION_PROFILE, + status="complete", + blocks=blocks, + warnings=(), + ) + extraction_id = extraction_digest(candidate) + return candidate.model_copy(update={"extraction_id": extraction_id}) + + @staticmethod + def _split(original: str) -> tuple[tuple[int, str], ...]: + chunks: list[tuple[int, str]] = [] + offset = 0 + ordinal = 0 + while offset < len(original): + end = min(offset + MAX_BLOCK_CODEPOINTS, len(original)) + if end < len(original): + boundary = original.rfind("\n", offset + 1, end) + if boundary > offset: + end = boundary + 1 + chunk = original[offset:end] + if not chunk: + raise TextExtractionError("Parser failed to advance through source text") + chunks.append((ordinal, chunk)) + offset = end + ordinal += 1 + return tuple(chunks) + + @staticmethod + def _split_transcript( + original: str, + ) -> tuple[tuple[int, str, int | None, int | None], ...]: + chunks: list[tuple[int, str, int | None, int | None]] = [] + lines = original.splitlines(keepends=True) + group: list[str] = [] + + def append_group() -> None: + if not group: + return + text = "".join(group) + group.clear() + timings = [line for line in text.splitlines() if "-->" in line] + if len(timings) > 1: + raise TextExtractionError("Transcript cue contains multiple timing lines") + if not timings: + chunks.append((len(chunks), text, None, None)) + return + match = re.fullmatch( + r"\s*((?:\d+:)?\d{2}:\d{2}[.,]\d{3})\s+-->\s+" + r"((?:\d+:)?\d{2}:\d{2}[.,]\d{3})(?:\s+.*)?\s*", + timings[0], + ) + if match is None: + raise TextExtractionError("Transcript cue timestamp is malformed") + start_ms = _timestamp_milliseconds(match.group(1)) + end_ms = _timestamp_milliseconds(match.group(2)) + if end_ms <= start_ms: + raise TextExtractionError("Transcript cue end must follow its start") + chunks.append((len(chunks), text, start_ms, end_ms)) + + for line in lines: + group.append(line) + if not line.strip(): + append_group() + append_group() + if not chunks or not any(start is not None for _, _, start, _ in chunks): + raise TextExtractionError("Transcript contains no timestamped cues") + return tuple(chunks) + + @staticmethod + def _block( + ordinal: int, + original_text: str, + time_start_ms: int | None = None, + time_end_ms: int | None = None, + ) -> ExtractionBlock: + normalized_parts: list[str] = [] + mapping: list[tuple[int, int]] = [] + for match in regex.finditer(r"\X", original_text): + normalized_cluster = unicodedata.normalize("NFC", match.group()) + normalized_parts.append(normalized_cluster) + mapping.extend((match.start(), match.end()) for _ in normalized_cluster) + normalized_text = "".join(normalized_parts) + source_digest = hashlib.sha256(original_text.encode("utf-8")).hexdigest()[:16] + return ExtractionBlock( + block_id=f"block-{ordinal:05d}-{source_digest}", + ordinal=ordinal, + original_text=original_text, + normalized_text=normalized_text, + normalization_map=tuple(mapping), + page=None, + time_start_ms=time_start_ms, + time_end_ms=time_end_ms, + ) + + +def _timestamp_milliseconds(value: str) -> int: + components = value.split(":") + if len(components) == 3: + hours, minutes, seconds_and_millis = components + elif len(components) == 2: + hours = "0" + minutes, seconds_and_millis = components + else: + raise TextExtractionError("Transcript timestamp is malformed") + seconds, separator, milliseconds = seconds_and_millis.partition(".") + if not separator: + seconds, separator, milliseconds = seconds_and_millis.partition(",") + if not separator: + raise TextExtractionError("Transcript timestamp is malformed") + minute_count = int(minutes) + hour_count = int(hours) + second_count = int(seconds) + if minute_count > 59 or second_count > 59 or len(milliseconds) != 3: + raise TextExtractionError("Transcript timestamp is outside the supported range") + return (((hour_count * 60) + minute_count) * 60 + second_count) * 1000 + int(milliseconds) + + +class _VisibleHtmlText(HTMLParser): + """Collect visible page text and discard non-content active elements.""" + + _BLOCK_TAGS = { + "address", + "article", + "blockquote", + "br", + "dd", + "div", + "dl", + "dt", + "footer", + "h1", + "h2", + "h3", + "h4", + "h5", + "h6", + "header", + "hr", + "li", + "main", + "ol", + "p", + "pre", + "section", + "table", + "tbody", + "td", + "th", + "tr", + "ul", + } + _HIDDEN_TAGS = {"script", "style", "template", "noscript"} + + def __init__(self) -> None: + super().__init__(convert_charrefs=True) + self.parts: list[str] = [] + self.hidden_tag: str | None = None + self.total_characters = 0 + + def handle_starttag(self, tag: str, _attrs: list[tuple[str, str | None]]) -> None: + if tag in self._HIDDEN_TAGS: + self.hidden_tag = tag + elif self.hidden_tag is None and tag in self._BLOCK_TAGS: + self.parts.append("\n") + + def handle_endtag(self, tag: str) -> None: + if self.hidden_tag == tag: + self.hidden_tag = None + elif self.hidden_tag is None and tag in self._BLOCK_TAGS: + self.parts.append("\n") + + def handle_data(self, data: str) -> None: + if self.hidden_tag is None: + self.total_characters += len(data) + if self.total_characters > 10_000_000: + raise TextExtractionError("HTML visible text exceeds the supported limit") + self.parts.append(data) + + +def _html_to_text(html: str) -> str: + parser = _VisibleHtmlText() + try: + for offset in range(0, len(html), 64 * 1024): + parser.feed(html[offset : offset + 64 * 1024]) + parser.close() + except TextExtractionError: + raise + except Exception as error: + raise TextExtractionError("HTML document could not be parsed") from error + joined = "".join(parser.parts).replace("\xa0", " ") + joined = re.sub(r"[\t\f\v ]+", " ", joined) + joined = re.sub(r" *\n *", "\n", joined) + joined = re.sub(r"\n{3,}", "\n\n", joined) + return joined.strip() diff --git a/src/gewu/adapters/url_fetch.py b/src/gewu/adapters/url_fetch.py new file mode 100644 index 0000000..5d336ec --- /dev/null +++ b/src/gewu/adapters/url_fetch.py @@ -0,0 +1,321 @@ +"""Bounded public-URL capture with explicit host and address validation.""" + +from __future__ import annotations + +import http.client +import ipaddress +import socket +import ssl +import threading +import time +from collections.abc import Callable, Sequence +from concurrent.futures import ThreadPoolExecutor +from concurrent.futures import TimeoutError as ResolverTimeout +from dataclasses import dataclass +from typing import Protocol, cast +from urllib.parse import SplitResult, urljoin, urlsplit + +MAX_CAPTURE_BYTES = 50 * 1024 * 1024 +MAX_REDIRECTS = 3 +CONNECT_TIMEOUT_SECONDS = 5 +DNS_TIMEOUT_SECONDS = 5 +READ_TIMEOUT_SECONDS = 15 +MAX_CAPTURE_SECONDS = 60 +REDIRECT_STATUSES = {301, 302, 303, 307, 308} +SUPPORTED_MEDIA_TYPES = { + "application/pdf", + "text/html", + "text/markdown", + "text/plain", +} + + +class UrlCaptureError(ValueError): + """Safe, stable failure returned for URL capture requests.""" + + def __init__(self, code: str, message: str) -> None: + super().__init__(message) + self.code = code + + +@dataclass(frozen=True, slots=True) +class UrlCapture: + original_url: str + final_url: str + media_type: str + title: str | None + content: bytes + + +Resolver = Callable[..., Sequence[tuple[int, int, int, str, tuple[object, ...]]]] + + +class ResponseLike(Protocol): + status: int + + def getheader(self, name: str) -> str | None: ... + + def read(self, amount: int) -> bytes: ... + + +class ConnectionLike(Protocol): + def request(self, method: str, path: str, headers: dict[str, str]) -> None: ... + + def getresponse(self) -> ResponseLike: ... + + def close(self) -> None: ... + + +ConnectionFactory = Callable[[str, str, int, str, int], ConnectionLike] + + +def _resolve_public_addresses(host: str, port: int, resolver: Resolver) -> tuple[str, ...]: + try: + results = resolver(host, port, type=socket.SOCK_STREAM) + except OSError as error: + raise UrlCaptureError("FETCH_UNAVAILABLE", "Source host could not be resolved") from error + addresses = tuple(dict.fromkeys(str(result[4][0]) for result in results)) + if not addresses: + raise UrlCaptureError("FETCH_UNAVAILABLE", "Source host has no usable address") + for address in addresses: + try: + parsed = ipaddress.ip_address(address) + except ValueError as error: + raise UrlCaptureError( + "FETCH_BLOCKED", "Source host resolved to an invalid address" + ) from error + if not parsed.is_global: + raise UrlCaptureError( + "FETCH_BLOCKED", "Private or reserved network targets are blocked" + ) + return addresses + + +def _parse_url(value: str) -> tuple[SplitResult, str, int]: + if len(value) > 4_000: + raise UrlCaptureError("URL_INVALID", "Source URL exceeds the supported length") + if any(ord(character) < 0x20 or ord(character) == 0x7F for character in value): + raise UrlCaptureError("URL_INVALID", "Source URL contains a control character") + try: + parsed = urlsplit(value) + port = parsed.port + except ValueError as error: + raise UrlCaptureError("URL_INVALID", "Source URL is invalid") from error + if parsed.scheme not in {"http", "https"} or not parsed.hostname: + raise UrlCaptureError("URL_INVALID", "Only HTTP and HTTPS source URLs are supported") + if parsed.username is not None or parsed.password is not None: + raise UrlCaptureError("URL_INVALID", "Credentials in source URLs are not supported") + raw_host = parsed.hostname + try: + host = ipaddress.ip_address(raw_host).compressed + except ValueError: + try: + host = raw_host.encode("idna").decode("ascii").lower().rstrip(".") + except UnicodeError as error: + raise UrlCaptureError("URL_INVALID", "Source URL hostname is invalid") from error + if not host or len(host) > 253: + raise UrlCaptureError("URL_INVALID", "Source URL hostname is invalid") + selected_port = port or (443 if parsed.scheme == "https" else 80) + if ( + selected_port not in {80, 443} + or (parsed.scheme == "https" and selected_port != 443) + or (parsed.scheme == "http" and selected_port != 80) + ): + raise UrlCaptureError("URL_INVALID", "Only standard HTTP and HTTPS ports are supported") + return parsed, host, selected_port + + +def _pinned_connection( + scheme: str, host: str, port: int, address: str, timeout: int +) -> http.client.HTTPConnection: + class PinnedHTTPConnection(http.client.HTTPConnection): + def connect(self) -> None: + self.sock = socket.create_connection((address, port), timeout=timeout) + self.sock.settimeout(READ_TIMEOUT_SECONDS) + + class PinnedHTTPSConnection(http.client.HTTPSConnection): + def connect(self) -> None: + raw_socket = socket.create_connection((address, port), timeout=timeout) + raw_socket.settimeout(READ_TIMEOUT_SECONDS) + context = ssl.create_default_context() + self.sock = context.wrap_socket(raw_socket, server_hostname=host) + + if scheme == "https": + return PinnedHTTPSConnection( + host, port, timeout=timeout, context=ssl.create_default_context() + ) + return PinnedHTTPConnection(host, port, timeout=timeout) + + +class PublicUrlFetcher: + """Fetch only explicitly allowed public hosts, pinning each request to checked DNS.""" + + def __init__( + self, + allowed_hosts: set[str] | frozenset[str], + *, + resolver: Resolver = socket.getaddrinfo, + connection_factory: ConnectionFactory | None = None, + ) -> None: + normalized_hosts: set[str] = set() + for host in allowed_hosts: + try: + normalized_hosts.add(ipaddress.ip_address(host).compressed) + except ValueError: + normalized_hosts.add(host.encode("idna").decode("ascii").lower().rstrip(".")) + self.allowed_hosts = frozenset(normalized_hosts) + self._resolver = resolver + self._connection_factory = ( + connection_factory + if connection_factory is not None + else cast(ConnectionFactory, _pinned_connection) + ) + self._capture_slots = threading.BoundedSemaphore(2) + self._resolver_slots = threading.BoundedSemaphore(2) + self._resolver_executor = ThreadPoolExecutor(max_workers=2, thread_name_prefix="gewu-dns") + + def _bounded_resolve( + self, host: str, port: int, *, type: int + ) -> Sequence[tuple[int, int, int, str, tuple[object, ...]]]: + if not self._resolver_slots.acquire(blocking=False): + raise UrlCaptureError("FETCH_BUSY", "URL resolver capacity is temporarily full") + future = self._resolver_executor.submit(self._resolver, host, port, type=type) + future.add_done_callback(lambda _future: self._resolver_slots.release()) + try: + return future.result(timeout=DNS_TIMEOUT_SECONDS) + except ResolverTimeout as error: + future.cancel() + raise UrlCaptureError("FETCH_TIMEOUT", "Source host resolution timed out") from error + + def fetch(self, url: str) -> UrlCapture: + if not self._capture_slots.acquire(blocking=False): + raise UrlCaptureError("FETCH_BUSY", "URL capture capacity is temporarily full") + try: + return self._fetch(url) + finally: + self._capture_slots.release() + + def _fetch(self, url: str) -> UrlCapture: + original_url = url + current_url = url + deadline = time.monotonic() + MAX_CAPTURE_SECONDS + for redirect_count in range(MAX_REDIRECTS + 1): + if time.monotonic() >= deadline: + raise UrlCaptureError("FETCH_TIMEOUT", "Source capture exceeded its time limit") + parsed, host, port = _parse_url(current_url) + if host not in self.allowed_hosts: + raise UrlCaptureError("FETCH_BLOCKED", "Source host is not allowlisted") + addresses = _resolve_public_addresses(host, port, self._bounded_resolve) + if time.monotonic() >= deadline: + raise UrlCaptureError("FETCH_TIMEOUT", "Source capture exceeded its time limit") + path = parsed.path or "/" + if parsed.query: + path += "?" + parsed.query + host_header = f"[{host}]" if ":" in host else host + if port != (443 if parsed.scheme == "https" else 80): + host_header += f":{port}" + response: ResponseLike | None = None + connection: ConnectionLike | None = None + last_error: OSError | http.client.HTTPException | ssl.SSLError | None = None + for address in addresses: + try: + connection = self._connection_factory( + parsed.scheme, host, port, address, CONNECT_TIMEOUT_SECONDS + ) + connection.request( + "GET", + path, + headers={ + "Host": host_header, + "User-Agent": "GEWU-source-capture/1.0", + "Accept": "application/pdf, text/html, text/plain, text/markdown", + "Accept-Encoding": "identity", + "Connection": "close", + }, + ) + response = connection.getresponse() + break + except (OSError, http.client.HTTPException, ssl.SSLError) as error: + last_error = error + if connection is not None: + connection.close() + if response is None or connection is None: + raise UrlCaptureError( + "FETCH_UNAVAILABLE", "Source host did not return a response" + ) from last_error + try: + if response.status in REDIRECT_STATUSES: + location = response.getheader("Location") + if redirect_count >= MAX_REDIRECTS or not location: + raise UrlCaptureError( + "FETCH_REDIRECT_LIMIT", "Source redirects exceed the supported limit" + ) + current_url = urljoin(current_url, location) + continue + if response.status < 200 or response.status >= 300: + raise UrlCaptureError( + "FETCH_FAILED", f"Source returned HTTP status {response.status}" + ) + media_type = (response.getheader("Content-Type") or "").split(";", 1)[0] + media_type = media_type.strip().lower() + if media_type not in SUPPORTED_MEDIA_TYPES: + raise UrlCaptureError( + "UNSUPPORTED_MEDIA_TYPE", "Source format is not supported for capture" + ) + if media_type == "application/pdf" and response.getheader( + "Content-Encoding" + ) not in {None, "", "identity"}: + raise UrlCaptureError( + "UNSUPPORTED_ENCODING", "Compressed HTTP responses are not supported" + ) + content_encoding = (response.getheader("Content-Encoding") or "identity").lower() + if content_encoding not in {"", "identity"}: + raise UrlCaptureError( + "UNSUPPORTED_ENCODING", "Compressed HTTP responses are not supported" + ) + declared_length = response.getheader("Content-Length") + if declared_length is not None: + try: + declared_size = int(declared_length) + except ValueError as error: + raise UrlCaptureError( + "FETCH_FAILED", "Source returned an invalid content length" + ) from error + if declared_size < 0: + raise UrlCaptureError( + "FETCH_FAILED", "Source returned an invalid content length" + ) + if declared_size > MAX_CAPTURE_BYTES: + raise UrlCaptureError( + "CAPTURE_TOO_LARGE", "Source exceeds the capture size limit" + ) + chunks: list[bytes] = [] + total = 0 + while block := response.read(min(64 * 1024, MAX_CAPTURE_BYTES - total + 1)): + if time.monotonic() >= deadline: + raise UrlCaptureError( + "FETCH_TIMEOUT", "Source capture exceeded its time limit" + ) + total += len(block) + if total > MAX_CAPTURE_BYTES: + raise UrlCaptureError( + "CAPTURE_TOO_LARGE", "Source exceeds the capture size limit" + ) + chunks.append(block) + if total == 0: + raise UrlCaptureError("FETCH_FAILED", "Source response was empty") + title = response.getheader("X-Source-Title") + return UrlCapture( + original_url=original_url, + final_url=current_url, + media_type=media_type, + title=title[:500] if title else None, + content=b"".join(chunks), + ) + except (OSError, http.client.HTTPException, TimeoutError) as error: + raise UrlCaptureError( + "FETCH_UNAVAILABLE", "Source transfer was interrupted" + ) from error + finally: + connection.close() + raise UrlCaptureError("FETCH_REDIRECT_LIMIT", "Source redirects exceed the supported limit") diff --git a/src/gewu/application/__init__.py b/src/gewu/application/__init__.py new file mode 100644 index 0000000..9ecfa0a --- /dev/null +++ b/src/gewu/application/__init__.py @@ -0,0 +1 @@ +"""GEWU use cases.""" diff --git a/src/gewu/application/acceptance.py b/src/gewu/application/acceptance.py new file mode 100644 index 0000000..98f583a --- /dev/null +++ b/src/gewu/application/acceptance.py @@ -0,0 +1,1006 @@ +"""Durable proposal evaluation, Git publication and receipt reconciliation.""" + +from __future__ import annotations + +import hashlib +import json +import os +import secrets +import threading +from collections.abc import Callable, Mapping +from dataclasses import dataclass, replace +from datetime import UTC, datetime +from pathlib import Path +from typing import Literal, cast + +import portalocker +import yaml +from pydantic import BaseModel, TypeAdapter, ValidationError + +from gewu.application.context import ValidationContext +from gewu.application.ports import ( + CanonicalConflictError, + CanonicalRepository, + CanonicalRepositoryError, +) +from gewu.application.serialization import safe_yaml_record +from gewu.application.uploads import LocalArtifactStore, UploadError +from gewu.domain.contracts import ( + AcceptanceManifest, + ChangedFile, + ExtractionRecord, + Finding, + KnowledgeNode, + OwnerDecisionReference, + PolicyBinding, + ProposalReceipt, + ProposalRevision, + ProposalState, + ProposalStatus, + RecordId, + SourceRevisionRecord, + TypedEdge, +) +from gewu.domain.validation import ( + ValidationReport, + canonical_json, + extraction_digest, + validate_proposal, +) + + +class AcceptanceError(RuntimeError): + """Application failure with a stable, non-sensitive code.""" + + def __init__(self, code: str, message: str) -> None: + super().__init__(message) + self.code = code + + +@dataclass(frozen=True, slots=True) +class ActorContext: + """Identity supplied by an authenticated transport adapter.""" + + principal_id: str + credential_id: str + roles: frozenset[str] | None = None + project_ids: frozenset[str] | None = None + readable_project_ids: frozenset[str] = frozenset() + + +@dataclass(frozen=True, slots=True) +class SubmissionResult: + validation: ValidationReport + receipt: ProposalReceipt | None + owner_challenge_ids: tuple[str, ...] = () + + +def _json_record(record: BaseModel) -> bytes: + return canonical_json(record.model_dump(mode="json")) + + +def _yaml_record(record: BaseModel) -> bytes: + value = record.model_dump(mode="json") + return yaml.safe_dump(value, allow_unicode=True, sort_keys=True, width=100).encode("utf-8") + + +def _safe_key(value: str) -> str: + return hashlib.sha256(value.encode("utf-8")).hexdigest() + + +class AcceptanceService: + """Accept eligible proposals through one recoverable canonical writer.""" + + def __init__( + self, + *, + repository: CanonicalRepository, + artifact_store: LocalArtifactStore, + workflow_root: Path, + context_provider: Callable[[], ValidationContext], + actor_is_current: Callable[[ActorContext], bool], + owner_challenge_issuer: Callable[..., None] | None = None, + owner_decision_lookup: Callable[..., tuple[dict[str, object], ...]] | None = None, + failure_hook: Callable[[str], None] = lambda _stage: None, + ) -> None: + self.repository = repository + self.artifact_store = artifact_store + self.root = workflow_root.expanduser().resolve() + self.context_provider = context_provider + self.actor_is_current = actor_is_current + self.owner_challenge_issuer = owner_challenge_issuer + self.owner_decision_lookup = owner_decision_lookup + self.failure_hook = failure_hook + self._thread_lock = threading.RLock() + self._lock_path = self.root / "locks" / "canonical-writer.lock" + self.proposals_root = self.root / "proposals" + self.journal_root = self.root / "journal" + self.receipts_root = self.root / "receipts" + self.publishing_root = self.root / "publishing" + for directory in ( + self._lock_path.parent, + self.proposals_root, + self.journal_root, + self.receipts_root, + self.publishing_root, + ): + directory.mkdir(parents=True, exist_ok=True, mode=0o700) + if directory.is_symlink() or not directory.is_dir(): + raise RuntimeError("GEWU workflow directory must be a real directory") + directory.chmod(0o700) + + def submit( + self, + proposal: ProposalRevision, + *, + actor: ActorContext, + staged_extractions: Mapping[str, ExtractionRecord] | None = None, + ) -> SubmissionResult: + """Persist, validate, policy-check and publish a proposal if eligible.""" + + if actor.principal_id != proposal.proposer_id: + raise AcceptanceError( + "UNAUTHORIZED", "Authenticated actor does not match proposal author" + ) + staged = dict(staged_extractions or {}) + if len(staged) > 100 or any( + key != record.extraction_id or extraction_digest(record) != key + for key, record in staged.items() + ): + raise AcceptanceError( + "EXTRACTION_HASH_MISMATCH", "Staged extraction identity is invalid" + ) + with self._thread_lock: + try: + with portalocker.Lock(self._lock_path, mode="a", timeout=0): + self._reconcile_pending() + return self._submit_locked(proposal, actor, staged) + except portalocker.exceptions.LockException as error: + raise AcceptanceError( + "BUSY", "Canonical writer is handling another proposal" + ) from error + + def receipt(self, proposal_id: str, revision: int) -> ProposalReceipt | None: + path = self._receipt_path(proposal_id, revision) + if path.is_symlink() or not path.is_file(): + return None + try: + return safe_yaml_record(path.read_bytes(), ProposalReceipt) + except (OSError, ValueError, yaml.YAMLError, ValidationError) as error: + raise AcceptanceError( + "RECOVERY_REQUIRED", "Durable receipt requires reconciliation" + ) from error + + def proposal_status( + self, proposal_id: str, revision: int, *, actor: ActorContext + ) -> ProposalStatus: + """Read lifecycle for the authenticated proposal author without exposing its payload.""" + + try: + safe_id = TypeAdapter(RecordId) + validated_id = safe_id.validate_python(proposal_id, strict=True) + except ValidationError as error: + raise AcceptanceError("PROPOSAL_NOT_FOUND", "Proposal status is unavailable") from error + if type(revision) is not int or revision < 1 or revision > 2**31 - 1: + raise AcceptanceError("PROPOSAL_NOT_FOUND", "Proposal status is unavailable") + proposal_path = self._proposal_path(validated_id, revision) + if proposal_path.is_symlink() or not proposal_path.is_file(): + raise AcceptanceError("PROPOSAL_NOT_FOUND", "Proposal status is unavailable") + try: + raw_proposal = proposal_path.read_bytes() + if len(raw_proposal) > 1_000_000: + raise ValueError("Proposal record exceeds its storage limit") + stored_proposal = ProposalRevision.model_validate_json(raw_proposal, strict=True) + except (OSError, ValueError, ValidationError) as error: + raise AcceptanceError("RECOVERY_REQUIRED", "Stored proposal is unavailable") from error + if stored_proposal.proposal_id != validated_id or stored_proposal.revision != revision: + raise AcceptanceError("RECOVERY_REQUIRED", "Stored proposal identity is invalid") + if stored_proposal.proposer_id != actor.principal_id: + raise AcceptanceError("PROPOSAL_NOT_FOUND", "Proposal status is unavailable") + + receipt = self.receipt(validated_id, revision) + if receipt is not None: + return ProposalStatus( + proposal_id=validated_id, + revision=revision, + lifecycle="accepted", + validation_status="eligible", + eligible_for_auto_accept=True, + findings=receipt.findings, + receipt=receipt, + canonical_revision=receipt.commit_id, + ) + + journal = self._journal_directory(validated_id, revision) + if journal.is_symlink() or not journal.exists(): + return ProposalStatus(proposal_id=validated_id, revision=revision, lifecycle="staged") + if not journal.is_dir(): + raise AcceptanceError("RECOVERY_REQUIRED", "Proposal journal is unavailable") + events = [path for path in journal.glob("*.json") if path.stem.isdigit()] + if len(events) > 1_000 or any(path.is_symlink() for path in events): + raise AcceptanceError("RECOVERY_REQUIRED", "Proposal journal exceeds its limit") + if not events: + return ProposalStatus(proposal_id=validated_id, revision=revision, lifecycle="staged") + latest = max(events, key=lambda path: int(path.stem)) + try: + if latest.is_symlink() or not latest.is_file(): + raise ValueError("Journal event is unavailable") + raw_event = latest.read_bytes() + if len(raw_event) > 64_000: + raise ValueError("Journal event exceeds its limit") + event = json.loads(raw_event) + except (OSError, UnicodeError, json.JSONDecodeError, ValueError) as error: + raise AcceptanceError("RECOVERY_REQUIRED", "Proposal journal is unavailable") from error + if ( + not isinstance(event, dict) + or event.get("proposal_id") != validated_id + or event.get("revision") != revision + ): + raise AcceptanceError("RECOVERY_REQUIRED", "Proposal journal identity is invalid") + lifecycle = event.get("status") + if not isinstance(lifecycle, str) or lifecycle not in { + "staged", + "evaluated", + "publishing", + "published-unreconciled", + "reconciled", + "withdrawn", + }: + raise AcceptanceError("RECOVERY_REQUIRED", "Proposal journal state is invalid") + if lifecycle == "reconciled": + raise AcceptanceError("RECOVERY_REQUIRED", "Accepted proposal receipt is unavailable") + validation_status = event.get("validation_status") + eligible = event.get("eligible_for_auto_accept") + findings_raw = event.get("findings", []) + if ( + ( + validation_status is not None + and ( + not isinstance(validation_status, str) + or validation_status not in {"eligible", "needs-review", "invalid", "conflict"} + ) + ) + or (eligible is not None and type(eligible) is not bool) + or (not isinstance(findings_raw, list) or len(findings_raw) > 200) + ): + raise AcceptanceError("RECOVERY_REQUIRED", "Proposal journal data is invalid") + try: + findings = tuple(Finding.model_validate(item, strict=True) for item in findings_raw) + except ValidationError as error: + raise AcceptanceError("RECOVERY_REQUIRED", "Proposal findings are invalid") from error + return ProposalStatus( + proposal_id=validated_id, + revision=revision, + lifecycle=cast( + Literal[ + "staged", + "evaluated", + "publishing", + "published-unreconciled", + "accepted", + "withdrawn", + ], + lifecycle, + ), + validation_status=cast( + Literal["eligible", "needs-review", "invalid", "conflict"] | None, + validation_status, + ), + eligible_for_auto_accept=eligible, + findings=findings, + ) + + def withdraw_proposal( + self, proposal_id: str, revision: int, *, actor: ActorContext + ) -> ProposalStatus: + """Withdraw an unpublished proposal atomically against the canonical writer.""" + + try: + validated_id = TypeAdapter(RecordId).validate_python(proposal_id, strict=True) + except ValidationError as error: + raise AcceptanceError("PROPOSAL_NOT_FOUND", "Proposal is unavailable") from error + if type(revision) is not int or revision < 1 or revision > 2**31 - 1: + raise AcceptanceError("PROPOSAL_NOT_FOUND", "Proposal is unavailable") + with self._thread_lock: + try: + with portalocker.Lock(self._lock_path, mode="a", timeout=0): + self._reconcile_pending() + proposal_path = self._proposal_path(validated_id, revision) + if proposal_path.is_symlink() or not proposal_path.is_file(): + raise AcceptanceError("PROPOSAL_NOT_FOUND", "Proposal is unavailable") + try: + raw_proposal = proposal_path.read_bytes() + if len(raw_proposal) > 1_000_000: + raise ValueError("Proposal record exceeds its storage limit") + stored_proposal = ProposalRevision.model_validate_json( + raw_proposal, strict=True + ) + except (OSError, ValueError, ValidationError) as error: + raise AcceptanceError( + "RECOVERY_REQUIRED", "Stored proposal is unavailable" + ) from error + if ( + stored_proposal.proposal_id != validated_id + or stored_proposal.revision != revision + or stored_proposal.proposer_id != actor.principal_id + ): + raise AcceptanceError("PROPOSAL_NOT_FOUND", "Proposal is unavailable") + existing_receipt = self.receipt(validated_id, revision) + if existing_receipt is not None: + raise AcceptanceError( + "PROPOSAL_ALREADY_ACCEPTED", "Accepted proposals cannot be withdrawn" + ) + status = self.proposal_status(validated_id, revision, actor=actor) + if status.lifecycle == "withdrawn": + return status + if not self.actor_is_current(actor): + raise AcceptanceError( + "UNAUTHORIZED", "Actor credential is no longer active" + ) + self._write_journal( + stored_proposal, + { + "status": "withdrawn", + "payload_digest": stored_proposal.payload_digest, + }, + ) + return self.proposal_status(validated_id, revision, actor=actor) + except portalocker.exceptions.LockException as error: + raise AcceptanceError( + "BUSY", "Canonical writer is handling another proposal" + ) from error + + def recover(self) -> None: + """Reconcile publication and receipt writes before serving new mutations.""" + + with self._thread_lock: + try: + with portalocker.Lock(self._lock_path, mode="a", timeout=0): + self._reconcile_pending() + except portalocker.exceptions.LockException as error: + raise AcceptanceError( + "BUSY", "Canonical writer recovery is already running" + ) from error + + def _submit_locked( + self, + proposal: ProposalRevision, + actor: ActorContext, + staged_extractions: Mapping[str, ExtractionRecord], + ) -> SubmissionResult: + proposal_path = self._proposal_path(proposal.proposal_id, proposal.revision) + payload = _json_record(proposal) + if proposal_path.exists(): + if proposal_path.is_symlink() or proposal_path.read_bytes() != payload: + raise AcceptanceError( + "IDEMPOTENCY_CONFLICT", + "Proposal ID and revision already bind different content", + ) + else: + self._atomic_write(proposal_path, payload) + + existing_receipt = self.receipt(proposal.proposal_id, proposal.revision) + if existing_receipt: + if existing_receipt.payload_digest != proposal.payload_digest: + raise AcceptanceError("IDEMPOTENCY_CONFLICT", "Proposal retry changed its content") + return SubmissionResult( + validation=ValidationReport( + status="accepted", + eligible_for_auto_accept=True, + findings=(), + affected_policy_ids=(), + ), + receipt=existing_receipt, + ) + + prior_status = self.proposal_status( + proposal.proposal_id, + proposal.revision, + actor=actor, + ) + if prior_status.lifecycle == "withdrawn": + raise AcceptanceError("PROPOSAL_WITHDRAWN", "Withdrawn proposals cannot be submitted") + + base = self.repository.head() + context = self.context_provider() + if staged_extractions: + context = replace( + context, + extractions={**context.extractions, **staged_extractions}, + ) + validation = validate_proposal( + proposal, + projects=dict(context.projects), + policies=dict(context.policies), + sources=dict(context.sources), + extractions=dict(context.extractions), + existing_nodes=dict(context.existing_nodes), + current_base_commit=base, + readable_project_ids=actor.readable_project_ids, + ) + self._write_journal( + proposal, + { + "status": "evaluated", + "payload_digest": proposal.payload_digest, + "expected_head": base, + "validation_status": validation.status, + "eligible_for_auto_accept": validation.eligible_for_auto_accept, + "findings": [item.model_dump(mode="json") for item in validation.findings], + }, + ) + owner_challenge_ids: tuple[str, ...] = () + owner_decisions: tuple[OwnerDecisionReference, ...] = () + if self._policy_exception_only(validation): + review_payload = self._owner_review_payload(proposal, context, validation, base) + if review_payload is not None: + diff_json, diff_digest, domains, owner_domains = review_payload + owner_challenge_ids = self._issue_owner_challenges( + proposal, + diff_json=diff_json, + diff_digest=diff_digest, + domains=domains, + owner_domains=owner_domains, + ) + owner_decisions = self._authorized_owner_decisions( + proposal, + diff_digest=diff_digest, + domains=domains, + owner_domains=owner_domains, + ) + if not validation.eligible_for_auto_accept and not owner_decisions: + return SubmissionResult( + validation=validation, + receipt=None, + owner_challenge_ids=owner_challenge_ids, + ) + + if not self.actor_is_current(actor): + raise AcceptanceError("UNAUTHORIZED", "Actor authority changed before publication") + if proposal.base_commit != base: + return SubmissionResult( + validation=validate_proposal( + proposal, + projects=dict(context.projects), + policies=dict(context.policies), + sources=dict(context.sources), + extractions=dict(context.extractions), + existing_nodes=dict(context.existing_nodes), + current_base_commit=base, + readable_project_ids=actor.readable_project_ids, + ), + receipt=None, + ) + + # Re-read authority and policies immediately before preparing canonical state. + fresh_context = self.context_provider() + if staged_extractions: + fresh_context = replace( + fresh_context, + extractions={**fresh_context.extractions, **staged_extractions}, + ) + fresh_validation = validate_proposal( + proposal, + projects=dict(fresh_context.projects), + policies=dict(fresh_context.policies), + sources=dict(fresh_context.sources), + extractions=dict(fresh_context.extractions), + existing_nodes=dict(fresh_context.existing_nodes), + current_base_commit=self.repository.head(), + readable_project_ids=actor.readable_project_ids, + ) + files = self._canonical_files(proposal, fresh_context) + changed_files = { + path: content + for path, content in files.items() + if self.repository.read_file(base, path) != content + } + fresh_owner_decisions: tuple[OwnerDecisionReference, ...] = () + if self._policy_exception_only(fresh_validation): + fresh_payload = self._owner_review_payload( + proposal, fresh_context, fresh_validation, base + ) + if fresh_payload is not None: + _diff_json, fresh_diff_digest, fresh_domains, fresh_owner_domains = fresh_payload + fresh_owner_decisions = self._authorized_owner_decisions( + proposal, + diff_digest=fresh_diff_digest, + domains=fresh_domains, + owner_domains=fresh_owner_domains, + ) + if not self.actor_is_current(actor) or not ( + fresh_validation.eligible_for_auto_accept or fresh_owner_decisions + ): + raise AcceptanceError( + "POLICY_CHANGED", "Proposal or owner authority changed before publication" + ) + if not changed_files: + raise AcceptanceError( + "NO_CANONICAL_CHANGE", "Proposal would not change canonical records" + ) + + bindings = tuple( + sorted( + ( + PolicyBinding( + project_id=policy.project_id, + policy_id=policy.policy_id, + policy_version=policy.policy_version, + ) + for policy in fresh_context.policies.values() + if policy.policy_id in fresh_validation.affected_policy_ids + ), + key=lambda binding: binding.project_id, + ) + ) + manifest_path = self._manifest_path(proposal.proposal_id, proposal.revision) + manifest = AcceptanceManifest( + proposal_id=proposal.proposal_id, + proposal_revision=proposal.revision, + payload_digest=proposal.payload_digest, + before_revision=base, + changed_files=tuple( + ChangedFile(path=path, content_sha256=hashlib.sha256(content).hexdigest()) + for path, content in sorted(changed_files.items()) + ), + accepted_node_ids=tuple( + operation.node.node_id + for operation in proposal.operations + if operation.node is not None + ), + actor_id=actor.principal_id, + credential_id=actor.credential_id, + policy_bindings=bindings, + owner_decisions=fresh_owner_decisions, + decided_at=datetime.now(UTC), + ) + manifest_bytes = _yaml_record(manifest) + commit_files = {**changed_files, manifest_path: manifest_bytes} + self._write_journal( + proposal, + { + "status": "publishing", + "payload_digest": proposal.payload_digest, + "expected_head": base, + "manifest_path": manifest_path, + "manifest_sha256": hashlib.sha256(manifest_bytes).hexdigest(), + }, + ) + self.failure_hook("before_commit") + try: + commit_id = self.repository.publish_files( + expected_head=base, + files=commit_files, + message=f"Accept proposal {proposal.proposal_id} revision {proposal.revision}", + index_directory=self.publishing_root, + ) + except CanonicalConflictError as error: + raise AcceptanceError( + "BASE_CONFLICT", "Canonical repository changed during publication" + ) from error + except CanonicalRepositoryError as error: + raise AcceptanceError( + "CANONICAL_WRITE_FAILED", "Canonical publication failed" + ) from error + self.failure_hook("after_ref_update") + self._write_journal( + proposal, + { + "status": "published-unreconciled", + "payload_digest": proposal.payload_digest, + "expected_head": base, + "manifest_path": manifest_path, + "manifest_sha256": hashlib.sha256(manifest_bytes).hexdigest(), + "commit_id": commit_id, + }, + ) + self.failure_hook("before_receipt") + receipt = self._reconcile_manifest(manifest_path, proposal.payload_digest) + self._write_journal( + proposal, + { + "status": "reconciled", + "payload_digest": proposal.payload_digest, + "manifest_path": manifest_path, + "receipt_sha256": hashlib.sha256(_yaml_record(receipt)).hexdigest(), + }, + ) + return SubmissionResult( + validation=fresh_validation, + receipt=receipt, + owner_challenge_ids=owner_challenge_ids, + ) + + @staticmethod + def _policy_exception_only(validation: ValidationReport) -> bool: + return validation.status == "needs-review" and { + finding.code for finding in validation.findings + } == {"AUTO_ACCEPT_UNSPECIFIED"} + + def _owner_review_payload( + self, + proposal: ProposalRevision, + context: ValidationContext, + validation: ValidationReport, + base: str, + ) -> tuple[str, str, tuple[str, ...], dict[str, tuple[str, ...]]] | None: + policies = tuple( + sorted( + ( + policy + for policy in context.policies.values() + if policy.policy_id in validation.affected_policy_ids + ), + key=lambda policy: (policy.project_id, policy.policy_id), + ) + ) + domains = tuple(sorted({policy.project_id for policy in policies})) + owner_domains: dict[str, list[str]] = {} + for domain in domains: + project = context.projects.get(domain) + if project is None or not project.owners: + return None + for owner in project.owners: + owner_domains.setdefault(owner, []).append(domain) + files = self._canonical_files(proposal, context) + changes = [] + for path, content in sorted(files.items()): + before = self.repository.read_file(base, path) + if before == content: + continue + try: + before_text = before.decode("utf-8") if before is not None else None + after_text = content.decode("utf-8") + except UnicodeDecodeError: + return None + changes.append({"path": path, "before": before_text, "after": after_text}) + if not changes: + return None + payload = { + "proposal_id": proposal.proposal_id, + "proposal_revision": proposal.revision, + "payload_digest": proposal.payload_digest, + "domains": domains, + "owners_by_domain": {domain: context.projects[domain].owners for domain in domains}, + "policy_bindings": [ + { + "project_id": policy.project_id, + "policy_id": policy.policy_id, + "policy_version": policy.policy_version, + } + for policy in policies + ], + "files": changes, + } + diff_json = canonical_json(payload).decode("utf-8") + if len(diff_json.encode("utf-8")) > 512_000: + return None + return ( + diff_json, + hashlib.sha256(diff_json.encode("utf-8")).hexdigest(), + domains, + {owner: tuple(sorted(items)) for owner, items in owner_domains.items()}, + ) + + def _issue_owner_challenges( + self, + proposal: ProposalRevision, + *, + diff_json: str, + diff_digest: str, + domains: tuple[str, ...], + owner_domains: dict[str, tuple[str, ...]], + ) -> tuple[str, ...]: + if self.owner_challenge_issuer is None: + return () + issued: list[str] = [] + covered: set[str] = set() + now = int(datetime.now(UTC).timestamp()) + for owner, owned_domains in sorted(owner_domains.items()): + challenge_id = ( + "owner-" + + hashlib.sha256( + f"{proposal.proposal_id}:{proposal.revision}:{owner}:{diff_digest}".encode() + ).hexdigest()[:40] + ) + try: + self.owner_challenge_issuer( + challenge_id=challenge_id, + owner_principal_id=owner, + proposal_id=proposal.proposal_id, + proposal_revision=proposal.revision, + payload_digest=proposal.payload_digest, + diff_digest=diff_digest, + domains=owned_domains, + expires_at=now + 300, + diff_json=diff_json, + now=now, + ) + except PermissionError: + continue + issued.append(challenge_id) + covered.update(owned_domains) + return tuple(issued) if set(domains) <= covered else () + + def _authorized_owner_decisions( + self, + proposal: ProposalRevision, + *, + diff_digest: str, + domains: tuple[str, ...], + owner_domains: dict[str, tuple[str, ...]], + ) -> tuple[OwnerDecisionReference, ...]: + if self.owner_decision_lookup is None: + return () + records = self.owner_decision_lookup( + proposal_id=proposal.proposal_id, + proposal_revision=proposal.revision, + payload_digest=proposal.payload_digest, + diff_digest=diff_digest, + ) + references: list[OwnerDecisionReference] = [] + covered: set[str] = set() + for record in records: + owner = record.get("owner_principal_id") + record_domains = record.get("domains") + decision_digest = record.get("decision_digest") + grant_version = record.get("grant_version") + rationale = record.get("rationale") + owner_session_id = record.get("owner_session_id") + decided_at = record.get("decided_at") + challenge_id = record.get("challenge_id") + if ( + record.get("decision") != "authorize" + or not isinstance(owner, str) + or not isinstance(record_domains, tuple) + or not isinstance(decision_digest, str) + or not isinstance(grant_version, int) + or not isinstance(rationale, str) + or not isinstance(owner_session_id, str) + or not isinstance(decided_at, int) + or not isinstance(challenge_id, str) + or not set(record_domains) <= set(owner_domains.get(owner, ())) + ): + continue + references.append( + OwnerDecisionReference( + challenge_id=challenge_id, + owner_principal_id=owner, + owner_session_id=owner_session_id, + decision_digest=decision_digest, + domains=record_domains, + grant_version=grant_version, + rationale=rationale, + decided_at=datetime.fromtimestamp(decided_at, UTC), + ) + ) + covered.update(record_domains) + return tuple(references) if set(domains) <= covered else () + + def _canonical_files( + self, + proposal: ProposalRevision, + context: ValidationContext, + ) -> dict[str, bytes]: + files: dict[str, bytes] = {} + referenced_extractions: set[str] = set() + for operation in proposal.operations: + if operation.source is not None: + source = operation.source + if source.archive_status == "archived": + self._verify_artifact(source) + files[self._source_path(source)] = _yaml_record(source) + if operation.node is not None: + node = operation.node + files[self._node_path(node)] = _yaml_record(node) + referenced_extractions.update( + evidence.extraction_id + for claim in node.evidence_claims + for evidence in claim.evidence + ) + if operation.edge is not None: + files[self._edge_path(operation.edge)] = _yaml_record(operation.edge) + referenced_extractions.update( + evidence.extraction_id for evidence in operation.edge.evidence + ) + for extraction_id in referenced_extractions: + extraction = context.extractions.get(extraction_id) + if extraction is None: + raise AcceptanceError("EXTRACTION_MISMATCH", "Cited extraction is unavailable") + files[f"extractions/{extraction_id}.json"] = _json_record(extraction) + return files + + def _verify_artifact(self, source: SourceRevisionRecord) -> None: + if source.artifact_sha256 is None: + raise AcceptanceError( + "SOURCE_ARTIFACT_MISSING", "Archived source artifact is unavailable" + ) + try: + with self.artifact_store.open_artifact(source.artifact_sha256) as stream: + digest = hashlib.sha256() + byte_length = 0 + while block := stream.read(1024 * 1024): + digest.update(block) + byte_length += len(block) + except UploadError as error: + raise AcceptanceError( + "SOURCE_ARTIFACT_MISSING", "Source artifact is unavailable" + ) from error + if byte_length != source.byte_length or digest.hexdigest() != source.artifact_sha256: + raise AcceptanceError( + "SOURCE_ARTIFACT_MISMATCH", "Source artifact failed integrity checks" + ) + + def _reconcile_pending(self) -> None: + for journal_directory in sorted(self.journal_root.iterdir()): + if journal_directory.is_symlink() or not journal_directory.is_dir(): + continue + journal_events = sorted(journal_directory.glob("*.json")) + if not journal_events: + continue + journal_path = journal_events[-1] + try: + entry = json.loads(journal_path.read_text(encoding="utf-8")) + if not isinstance(entry, dict) or entry.get("status") not in { + "publishing", + "published-unreconciled", + }: + continue + manifest_path = entry.get("manifest_path") + payload_digest = entry.get("payload_digest") + if not isinstance(manifest_path, str) or not isinstance(payload_digest, str): + raise ValueError("journal binding missing") + if self.repository.read_file(self.repository.head(), manifest_path) is None: + if entry.get("status") == "published-unreconciled": + raise ValueError("published manifest missing from canonical history") + self._append_journal_event(journal_directory, {**entry, "status": "staged"}) + continue + expected_hash = entry.get("manifest_sha256") + receipt = self._reconcile_manifest( + manifest_path, + payload_digest, + expected_manifest_sha256=expected_hash + if isinstance(expected_hash, str) + else None, + ) + self._append_journal_event( + journal_directory, + { + **entry, + "status": "reconciled", + "commit_id": receipt.commit_id, + "receipt_sha256": hashlib.sha256(_yaml_record(receipt)).hexdigest(), + }, + ) + except (OSError, ValueError, KeyError, yaml.YAMLError, ValidationError) as error: + raise AcceptanceError( + "RECOVERY_REQUIRED", "Canonical publication requires operator reconciliation" + ) from error + + def _reconcile_manifest( + self, + manifest_path: str, + payload_digest: str, + *, + expected_manifest_sha256: str | None = None, + ) -> ProposalReceipt: + head = self.repository.head() + raw = self.repository.read_file(head, manifest_path) + if raw is None: + raise AcceptanceError( + "RECOVERY_REQUIRED", "Acceptance manifest is not on the canonical branch" + ) + manifest = safe_yaml_record(raw, AcceptanceManifest) + if manifest.payload_digest != payload_digest: + raise AcceptanceError( + "RECOVERY_REQUIRED", "Acceptance manifest digest does not match proposal" + ) + manifest_sha256 = hashlib.sha256(raw).hexdigest() + if expected_manifest_sha256 and not secrets.compare_digest( + manifest_sha256, expected_manifest_sha256 + ): + raise AcceptanceError( + "RECOVERY_REQUIRED", "Manifest hash does not match publication journal" + ) + for changed in manifest.changed_files: + stored = self.repository.read_file(head, changed.path) + if stored is None or not secrets.compare_digest( + hashlib.sha256(stored).hexdigest(), changed.content_sha256 + ): + raise AcceptanceError( + "RECOVERY_REQUIRED", "Accepted record failed manifest verification" + ) + commit_id = self.repository.introducing_commit(head, manifest_path) + if commit_id is None: + raise AcceptanceError( + "RECOVERY_REQUIRED", "Acceptance manifest has no introducing commit" + ) + receipt = ProposalReceipt( + proposal_id=manifest.proposal_id, + revision=manifest.proposal_revision, + payload_digest=manifest.payload_digest, + state=ProposalState.ACCEPTED, + outcome="accepted", + commit_id=commit_id, + accepted_node_ids=manifest.accepted_node_ids, + findings=(), + decided_at=manifest.decided_at, + ) + receipt_path = self._receipt_path(manifest.proposal_id, manifest.proposal_revision) + serialized = _yaml_record(receipt) + if receipt_path.exists() and receipt_path.read_bytes() != serialized: + raise AcceptanceError( + "RECOVERY_REQUIRED", "Existing receipt conflicts with acceptance manifest" + ) + if not receipt_path.exists(): + self._atomic_write(receipt_path, serialized) + if self.repository.read_file(commit_id, manifest_path) is None: + raise AcceptanceError( + "RECOVERY_REQUIRED", "Manifest was not introduced by an accepted commit" + ) + return receipt + + def _write_journal(self, proposal: ProposalRevision, values: dict[str, object]) -> None: + payload = { + "proposal_id": proposal.proposal_id, + "revision": proposal.revision, + **values, + } + self._append_journal_event( + self._journal_directory(proposal.proposal_id, proposal.revision), payload + ) + + def _append_journal_event(self, directory: Path, payload: dict[str, object]) -> None: + directory.mkdir(parents=True, exist_ok=True, mode=0o700) + if directory.is_symlink(): + raise AcceptanceError("PRIVATE_STORE_UNAVAILABLE", "Workflow storage is unavailable") + event_paths = [path for path in directory.glob("*.json") if path.stem.isdigit()] + sequence = 1 + max((int(path.stem) for path in event_paths), default=0) + self._atomic_write(directory / f"{sequence:08d}.json", canonical_json(payload)) + + @staticmethod + def _atomic_write(path: Path, content: bytes) -> None: + path.parent.mkdir(parents=True, exist_ok=True, mode=0o700) + if path.parent.is_symlink() or path.is_symlink(): + raise AcceptanceError("PRIVATE_STORE_UNAVAILABLE", "Workflow storage is unavailable") + path.parent.chmod(0o700) + temporary = path.with_name(f".{path.name}.{secrets.token_hex(12)}.tmp") + try: + with temporary.open("xb") as stream: + stream.write(content) + stream.flush() + os.fsync(stream.fileno()) + os.replace(temporary, path) + if os.name != "nt": + descriptor = os.open(path.parent, os.O_RDONLY) + try: + os.fsync(descriptor) + finally: + os.close(descriptor) + finally: + temporary.unlink(missing_ok=True) + + def _proposal_path(self, proposal_id: str, revision: int) -> Path: + return self.proposals_root / _safe_key(proposal_id) / f"{revision}.json" + + def _journal_directory(self, proposal_id: str, revision: int) -> Path: + return self.journal_root / f"{_safe_key(proposal_id)}-{revision}" + + def _receipt_path(self, proposal_id: str, revision: int) -> Path: + return self.receipts_root / _safe_key(proposal_id) / f"{revision}.yaml" + + @staticmethod + def _manifest_path(proposal_id: str, revision: int) -> str: + return f"acceptance/{_safe_key(proposal_id)}/{revision}.yaml" + + @staticmethod + def _source_path(source: SourceRevisionRecord) -> str: + return f"sources/{_safe_key(source.source_id)}/{_safe_key(source.revision_id)}.yaml" + + @staticmethod + def _node_path(node: KnowledgeNode) -> str: + return f"projects/{_safe_key(node.project_id)}/nodes/{_safe_key(node.node_id)}.yaml" + + @staticmethod + def _edge_path(edge: TypedEdge) -> str: + return f"edges/{_safe_key(edge.edge_id)}.yaml" diff --git a/src/gewu/application/accounts.py b/src/gewu/application/accounts.py new file mode 100644 index 0000000..9d57a74 --- /dev/null +++ b/src/gewu/application/accounts.py @@ -0,0 +1,52 @@ +"""Human accounts and account-owned agent credential contracts.""" + +from dataclasses import dataclass +from typing import Protocol + +from gewu.application.admin import CredentialSummary +from gewu.application.identity import BrowserSession, Principal + + +@dataclass(frozen=True) +class AccountSummary: + username: str + principal_id: str + role: str + project_ids: list[str] + active: bool + + +class AccountIdentity(Protocol): + def create_account( + self, + *, + username: str, + password: str, + role: str, + project_ids: tuple[str, ...], + actor: str, + ) -> AccountSummary: ... + + def list_accounts(self, *, limit: int, offset: int) -> list[AccountSummary]: ... + + def login_account(self, username: str, password: str) -> BrowserSession | None: ... + + def change_account_password( + self, principal: Principal, current_password: str, new_password: str + ) -> bool: ... + + def update_account( + self, username: str, *, role: str, project_ids: tuple[str, ...], actor: Principal + ) -> AccountSummary | None: ... + + def disable_account(self, username: str, *, actor: Principal) -> bool: ... + + def list_agent_credentials( + self, principal: Principal, *, limit: int, offset: int + ) -> list[CredentialSummary]: ... + + def create_agent_credential( + self, principal: Principal, *, name: str, project_ids: tuple[str, ...] + ) -> tuple[str, str]: ... + + def revoke_agent_credential(self, principal: Principal, credential_id: str) -> bool: ... diff --git a/src/gewu/application/admin.py b/src/gewu/application/admin.py new file mode 100644 index 0000000..fb96465 --- /dev/null +++ b/src/gewu/application/admin.py @@ -0,0 +1,45 @@ +"""Secret-free administration records and credential management port.""" + +from dataclasses import dataclass +from typing import Protocol + + +@dataclass(frozen=True) +class CredentialSummary: + credential_id: str + principal_id: str + roles: list[str] + project_ids: list[str] + revoked: bool + owner_principal_id: str | None = None + + +@dataclass(frozen=True) +class AdminEvent: + event_id: int + occurred_at: str + actor: str + action: str + credential_id: str + + +class CredentialAdministration(Protocol): + def list_credentials(self, *, limit: int, offset: int) -> list[CredentialSummary]: ... + + def list_admin_events(self, *, limit: int) -> list[AdminEvent]: ... + + def provision( + self, + *, + credential_id: str, + principal_id: str, + roles: tuple[str, ...], + project_ids: tuple[str, ...] = (), + actor: str | None = None, + ) -> str: ... + + def update_credential( + self, credential_id: str, *, role: str, project_ids: tuple[str, ...], actor: str + ) -> CredentialSummary | None: ... + + def revoke(self, credential_id: str, *, actor: str | None = None) -> bool: ... diff --git a/src/gewu/application/context.py b/src/gewu/application/context.py new file mode 100644 index 0000000..db23563 --- /dev/null +++ b/src/gewu/application/context.py @@ -0,0 +1,23 @@ +"""Immutable validation snapshot passed into proposal acceptance.""" + +from __future__ import annotations + +from collections.abc import Mapping +from dataclasses import dataclass + +from gewu.domain.contracts import ( + AutoAcceptPolicy, + ExtractionRecord, + KnowledgeNode, + ProjectRecord, + SourceRevisionRecord, +) + + +@dataclass(frozen=True, slots=True) +class ValidationContext: + projects: Mapping[str, ProjectRecord] + policies: Mapping[str, AutoAcceptPolicy] + sources: Mapping[str, SourceRevisionRecord] + extractions: Mapping[str, ExtractionRecord] + existing_nodes: Mapping[str, KnowledgeNode] diff --git a/src/gewu/application/diagnostics.py b/src/gewu/application/diagnostics.py new file mode 100644 index 0000000..ad8b8f5 --- /dev/null +++ b/src/gewu/application/diagnostics.py @@ -0,0 +1,88 @@ +"""Authenticated, secret-free service compatibility diagnostics.""" + +from __future__ import annotations + +from typing import Literal, cast + +from gewu.application.identity import Principal +from gewu.application.uploads import ALLOWED_MEDIA_TYPES, CHUNK_BYTES, MAX_ARTIFACT_BYTES +from gewu.domain.contracts import Scope, ServiceDiagnostics +from gewu.domain.operations import Transport, catalog_digest, operations_for +from gewu.version import SERVICE_VERSION + + +def build_service_diagnostics( + *, + principal: Principal, + transport: Literal["http", "mcp"], + authorized_projects: tuple[str, ...], + available_operations: frozenset[str], + index_status: tuple[str, str | None, str, int | None], + projects: tuple[str, ...] | None = None, + include_shared: bool = False, +) -> ServiceDiagnostics: + """Build caller-specific diagnostics without exposing credential identifiers.""" + + visible_projects = tuple(sorted(set(authorized_projects))) + if projects is None: + if include_shared: + raise ValueError("Select at least one project before including shared knowledge") + selected_scope = None + else: + if not projects or len(set(projects)) != len(projects): + raise ValueError("Selected projects must be non-empty and unique") + if any(project not in visible_projects for project in projects): + raise PermissionError("Selected scope includes an unreadable project") + if include_shared and not principal.can_read_project("shared"): + raise PermissionError("Credential cannot read shared knowledge") + selected_scope = Scope(projects=projects, include_shared=include_shared) + + capabilities: set[str] = {"service:diagnostics"} + if "admin" in principal.roles and "agent" not in principal.roles: + capabilities.update({"project:admin", "credential:admin"}) + if principal.roles.intersection({"admin", "editor", "viewer", "agent"}): + capabilities.add("knowledge:read") + if principal.auth_method == "bearer" and "agent" in principal.roles: + capabilities.update( + { + "proposal:submit", + "proposal:evaluate", + "proposal:read", + "proposal:withdraw", + "run:read", + "run:write", + } + ) + if principal.roles.intersection({"admin", "editor", "viewer"}): + capabilities.add("identity:session") + + catalog_transport = Transport(transport) + authorized_operations = tuple( + sorted( + operation.name + for operation in operations_for(catalog_transport) + if operation.name in available_operations and operation.capability in capabilities + ) + ) + canonical, index_revision, index_state, index_schema_version = index_status + return ServiceDiagnostics( + service_version=SERVICE_VERSION, + catalog_sha256=catalog_digest(), + transport=transport, + authentication=cast(Literal["bearer", "browser_session"], principal.auth_method), + roles=tuple(sorted(principal.roles)), + authorized_projects=visible_projects, + selected_scope=selected_scope, + capabilities=tuple(sorted(capabilities)), + authorized_operations=authorized_operations, + max_upload_bytes=MAX_ARTIFACT_BYTES, + upload_chunk_bytes=CHUNK_BYTES, + supported_media_types=tuple(sorted(ALLOWED_MEDIA_TYPES)), + canonical_revision=canonical, + index_state=cast( + Literal["current", "lagging", "not_built", "not_configured", "unavailable"], + index_state, + ), + index_revision=index_revision, + index_schema_version=index_schema_version, + ) diff --git a/src/gewu/application/identity.py b/src/gewu/application/identity.py new file mode 100644 index 0000000..bd6948c --- /dev/null +++ b/src/gewu/application/identity.py @@ -0,0 +1,75 @@ +"""Authenticated principals and capability checks shared by transports.""" + +from __future__ import annotations + +from dataclasses import dataclass +from datetime import datetime +from typing import Protocol + + +@dataclass(frozen=True, slots=True) +class Principal: + principal_id: str + credential_id: str + roles: frozenset[str] + project_ids: frozenset[str] + auth_method: str = "bearer" + + def can_read_project(self, project_id: str) -> bool: + return "admin" in self.roles or project_id in self.project_ids + + def can_submit_project(self, project_id: str) -> bool: + return ( + self.auth_method == "bearer" + and "agent" in self.roles + and project_id in self.project_ids + ) + + +@dataclass(frozen=True, slots=True) +class BrowserSession: + session_token: str + csrf_token: str + expires_at: datetime + + +@dataclass(frozen=True, slots=True) +class OwnerSession: + session_token: str + expires_at: datetime + challenge_id: str + + +class IdentityProvider(Protocol): + def authenticate(self, bearer_token: str) -> Principal | None: ... + + def create_browser_session(self, bearer_token: str) -> BrowserSession: ... + + def authenticate_browser_session(self, session_token: str) -> Principal | None: ... + + def restore_browser_session(self, session_token: str) -> BrowserSession | None: ... + + def browser_csrf_valid(self, session_token: str, csrf_token: str) -> bool: ... + + def revoke_browser_session(self, session_token: str) -> bool: ... + + +class OwnerIdentityProvider(Protocol): + """Owner-only identity surface, separate from agent/browser authentication.""" + + @property + def owner_authentication_enabled(self) -> bool: ... + + def authenticate_owner( + self, + *, + principal_id: str, + password: str, + totp_code: str, + challenge_id: str, + audience: str, + ) -> OwnerSession | None: ... + + def authenticate_owner_session( + self, session_token: str, *, challenge_id: str, audience: str + ) -> Principal | None: ... diff --git a/src/gewu/application/paths.py b/src/gewu/application/paths.py new file mode 100644 index 0000000..9d88f1e --- /dev/null +++ b/src/gewu/application/paths.py @@ -0,0 +1,13 @@ +"""Filesystem checks shared by private local persistence adapters.""" + +from __future__ import annotations + +from pathlib import Path + + +def reject_symlink_components(path: Path) -> None: + """Reject a path whose existing component redirects persistence elsewhere.""" + + for component in (path, *path.parents): + if component.is_symlink(): + raise ValueError("Private data paths must not traverse symbolic links") diff --git a/src/gewu/application/ports.py b/src/gewu/application/ports.py new file mode 100644 index 0000000..89b7e02 --- /dev/null +++ b/src/gewu/application/ports.py @@ -0,0 +1,177 @@ +"""Application-owned ports implemented by replaceable persistence adapters.""" + +from __future__ import annotations + +import re +import sqlite3 +from collections.abc import Iterable, Mapping +from dataclasses import dataclass +from pathlib import Path, PurePosixPath +from typing import Protocol + +from gewu.domain.contracts import AutoAcceptPolicy, ProjectRecord + + +class CanonicalRepositoryError(RuntimeError): + """A committed-record operation failed without exposing backend details.""" + + +class CanonicalConflictError(CanonicalRepositoryError): + """Expected-head publication lost its compare-and-swap race.""" + + +_CANONICAL_PATH = re.compile(r"^[A-Za-z0-9._/-]+$") + + +def validate_canonical_path(path: str) -> None: + """Enforce portable, relative POSIX paths for all repository implementations.""" + + pure = PurePosixPath(path) + if ( + not path + or path.startswith("/") + or "\\" in path + or not _CANONICAL_PATH.fullmatch(path) + or pure.as_posix() != path + or any(part in {"", ".", ".."} for part in pure.parts) + ): + raise CanonicalRepositoryError("Canonical record path is invalid") + + +class CanonicalRepository(Protocol): + """Committed reads and expected-head atomic publication. + + ``history`` returns newest-first revisions that changed the requested path; + ``introducing_commit`` returns the oldest revision in that path history. + Paths use ``validate_canonical_path``. Implementations raise the port-owned + errors above, never backend exceptions. + """ + + def head(self) -> str: ... + + def read_file(self, revision: str, path: str) -> bytes | None: ... + + def list_files(self, revision: str, prefix: str) -> tuple[str, ...]: ... + + def publish_files( + self, + *, + expected_head: str, + files: Mapping[str, bytes], + message: str, + index_directory: Path, + ) -> str: ... + + def introducing_commit(self, revision: str, path: str) -> str | None: ... + + def history(self, revision: str, path: str, limit: int) -> tuple[str, ...]: ... + + +class ProjectRegistry(Protocol): + """Versioned project and policy registry with serialized lifecycle writes.""" + + def records(self) -> tuple[ProjectRecord, ...]: ... + + def create(self, project: ProjectRecord, policy: AutoAcceptPolicy) -> ProjectRecord: ... + + def update_content(self, project_id: str, *, description: str, north_star: str, key_questions: tuple[str, ...]) -> ProjectRecord: ... + + def archive(self, project_id: str) -> ProjectRecord: ... + + +class EmbeddingProvider(Protocol): + """Replaceable, normalized text-vector provider for derived retrieval indexes.""" + + @property + def dimensions(self) -> int: ... + + @property + def model_id(self) -> str: ... + + @property + def artifact_sha256(self) -> str: ... + + def embed_queries(self, texts: tuple[str, ...]) -> tuple[tuple[float, ...], ...]: ... + + def embed_passages(self, texts: tuple[str, ...]) -> tuple[tuple[float, ...], ...]: ... + + +@dataclass(frozen=True, slots=True) +class VectorHit: + node_id: str + project_id: str + distance: float + + +@dataclass(frozen=True, slots=True) +class VectorGeneration: + generation_id: str + revision: str + model_id: str + artifact_sha256: str + content_profile: str + dimensions: int + node_count: int + + +class VectorIndex(Protocol): + """Scoped vector candidate lookup over an activated immutable generation.""" + + def search( + self, + *, + query: tuple[float, ...], + project_ids: tuple[str, ...], + include_shared: bool, + limit: int, + expected_generation_id: str | None = None, + ) -> tuple[VectorHit, ...]: ... + + def active_generation(self) -> VectorGeneration | None: ... + + def generation(self, generation_id: str) -> VectorGeneration | None: ... + + def build_generation( + self, + *, + generation_id: str, + revision: str, + model_id: str, + artifact_sha256: str, + content_profile: str, + dimensions: int, + vectors: Iterable[tuple[str, str, tuple[float, ...]]], + ) -> None: ... + + def activate(self, generation_id: str, *, expected_active: str | None) -> str | None: ... + + def active_generation_in_transaction( + self, connection: sqlite3.Connection + ) -> VectorGeneration | None: ... + + def generation_in_transaction( + self, connection: sqlite3.Connection, generation_id: str + ) -> VectorGeneration | None: ... + + def build_generation_in_transaction( + self, + connection: sqlite3.Connection, + *, + generation_id: str, + revision: str, + model_id: str, + artifact_sha256: str, + content_profile: str, + dimensions: int, + vectors: Iterable[tuple[str, str, tuple[float, ...]]], + ) -> None: ... + + def activate_in_transaction( + self, + connection: sqlite3.Connection, + generation_id: str, + *, + expected_active: str | None, + ) -> str | None: ... + + def deactivate_in_transaction(self, connection: sqlite3.Connection) -> None: ... diff --git a/src/gewu/application/reading.py b/src/gewu/application/reading.py new file mode 100644 index 0000000..766bbd1 --- /dev/null +++ b/src/gewu/application/reading.py @@ -0,0 +1,218 @@ +"""Bounded canonical reads for a single project node and its citations.""" + +from __future__ import annotations + +import hashlib +import json +import secrets +from dataclasses import dataclass + +import yaml +from pydantic import TypeAdapter, ValidationError + +from gewu.application.identity import Principal +from gewu.application.ports import CanonicalRepository +from gewu.domain.contracts import ( + BudgetProfile, + EvidenceReference, + GitCommitId, + KnowledgeNode, + ReadEnvelope, + Scope, + SourceRevisionRecord, +) + + +class ReadError(RuntimeError): + def __init__(self, status_code: int, message: str) -> None: + super().__init__(message) + self.status_code = status_code + + +def _key(value: str) -> str: + return hashlib.sha256(value.encode("utf-8")).hexdigest() + + +_GIT_COMMIT_ID = TypeAdapter(GitCommitId) + + +@dataclass(frozen=True, slots=True) +class CanonicalNodeReader: + repository: CanonicalRepository + + def get_node( + self, + project_id: str, + node_id: str, + *, + principal: Principal, + revision: str | None = None, + ) -> ReadEnvelope: + if not principal.can_read_project(project_id): + raise ReadError(403, "Credential cannot read this project") + head = self.repository.head() + path = f"projects/{_key(project_id)}/nodes/{_key(node_id)}.yaml" + if revision is None: + selected_revision = head + else: + try: + selected_revision = _GIT_COMMIT_ID.validate_python(revision, strict=True) + except ValidationError as error: + raise ReadError(422, "Canonical node revision is invalid") from error + if selected_revision not in self.repository.history(head, path, 20): + raise ReadError(404, "Knowledge node revision was not found") + raw = self.repository.read_file(selected_revision, path) + if raw is None: + raise ReadError(404, "Knowledge node was not found") + try: + value = yaml.safe_load(raw) + node = KnowledgeNode.model_validate_json( + json.dumps(value, ensure_ascii=False), strict=True + ) + except (ValueError, yaml.YAMLError, ValidationError) as error: + raise ReadError(503, "Canonical knowledge record failed validation") from error + if node.node_id != node_id or node.project_id != project_id: + raise ReadError(503, "Canonical record identity does not match its path") + citations = tuple(evidence for claim in node.evidence_claims for evidence in claim.evidence) + envelope = ReadEnvelope( + request_id="read-" + secrets.token_hex(12), + resolved_scope=Scope( + projects=(project_id,), include_shared=principal.can_read_project("shared") + ), + data=(node.model_dump(mode="json"),), + citations=citations, + canonical_revision=selected_revision, + index_revision=None, + retrieval_mode="canonical", + budget_profile=BudgetProfile(tokenizer="none", encoding="raw-record-v1"), + tokens_used=0, + ) + if len(envelope.model_dump_json().encode("utf-8")) > 1_000_000: + raise ReadError(413, "Canonical node exceeds the read response budget") + return envelope + + def get_history( + self, project_id: str, node_id: str, *, principal: Principal, limit: int = 20 + ) -> ReadEnvelope: + if not 1 <= limit <= 20: + raise ReadError(422, "History limit must be between one and twenty") + if not principal.can_read_project(project_id): + raise ReadError(403, "Credential cannot read this project") + path = f"projects/{_key(project_id)}/nodes/{_key(node_id)}.yaml" + head = self.repository.head() + rows: list[dict[str, object]] = [] + citations: dict[str, EvidenceReference] = {} + for revision in self.repository.history(head, path, limit): + raw = self.repository.read_file(revision, path) + if raw is None: + continue + node = _validated_record( + raw, KnowledgeNode, "Canonical knowledge record failed validation" + ) + if node.node_id != node_id or node.project_id != project_id: + raise ReadError(503, "Canonical record identity does not match its path") + rows.append({"revision": revision, "node": node.model_dump(mode="json")}) + for claim in node.evidence_claims: + for evidence in claim.evidence: + citations[evidence.model_dump_json()] = evidence + return _envelope( + request_id="history-" + secrets.token_hex(12), + project_id=project_id, + include_shared=principal.can_read_project("shared"), + data=tuple(rows), + citations=tuple(citations[key] for key in sorted(citations)), + revision=head, + ) + + +@dataclass(frozen=True, slots=True) +class CanonicalSourceReader: + repository: CanonicalRepository + + def get_source( + self, + project_id: str, + revision_id: str, + *, + principal: Principal, + ) -> ReadEnvelope: + source, head = self.get_source_record(project_id, revision_id, principal=principal) + return _envelope( + request_id="source-" + secrets.token_hex(12), + project_id=project_id, + include_shared=principal.can_read_project("shared"), + data=(source.model_dump(mode="json"),), + citations=(), + revision=head, + ) + + def get_source_record( + self, + project_id: str, + revision_id: str, + *, + principal: Principal, + ) -> tuple[SourceRevisionRecord, str]: + if not principal.can_read_project(project_id): + raise ReadError(403, "Credential cannot read this project") + head = self.repository.head() + source_key = _key(revision_id) + source_paths = self.repository.list_files(head, "sources") + if len(source_paths) > 100_000: + raise ReadError(503, "Canonical source registry exceeds the read limit") + matching_path = next( + (path for path in source_paths if path.endswith(f"/{source_key}.yaml")), None + ) + if matching_path is None: + raise ReadError(404, "Source revision was not found within the requested project") + raw = self.repository.read_file(head, matching_path) + if raw is None: + raise ReadError(404, "Source revision was not found within the requested project") + source = _validated_record( + raw, SourceRevisionRecord, "Canonical source record failed validation" + ) + if ( + source.revision_id != revision_id + or matching_path != f"sources/{_key(source.source_id)}/{_key(source.revision_id)}.yaml" + ): + raise ReadError(503, "Canonical source identity does not match its path") + if project_id not in source.ingested_for: + raise ReadError(404, "Source revision was not found within the requested project") + return source, head + + +def _validated_record[RecordT: (KnowledgeNode, SourceRevisionRecord)]( + raw: bytes, record_type: type[RecordT], message: str +) -> RecordT: + if len(raw) > 1_048_576: + raise ReadError(503, message) + try: + value = yaml.safe_load(raw) + return record_type.model_validate_json(json.dumps(value, ensure_ascii=False), strict=True) + except (ValueError, yaml.YAMLError, ValidationError) as error: + raise ReadError(503, message) from error + + +def _envelope( + *, + request_id: str, + project_id: str, + include_shared: bool, + data: tuple[dict[str, object], ...], + citations: tuple[EvidenceReference, ...], + revision: str, +) -> ReadEnvelope: + envelope = ReadEnvelope( + request_id=request_id, + resolved_scope=Scope(projects=(project_id,), include_shared=include_shared), + data=data, + citations=citations, + canonical_revision=revision, + index_revision=None, + retrieval_mode="canonical", + budget_profile=BudgetProfile(tokenizer="none", encoding="raw-record-v1"), + tokens_used=0, + ) + if len(envelope.model_dump_json().encode("utf-8")) > 1_000_000: + raise ReadError(413, "Canonical read exceeds the response budget") + return envelope diff --git a/src/gewu/application/registry.py b/src/gewu/application/registry.py new file mode 100644 index 0000000..925bcda --- /dev/null +++ b/src/gewu/application/registry.py @@ -0,0 +1,426 @@ +"""Local project/policy configuration and committed-record context provider.""" + +from __future__ import annotations + +import json +from contextlib import AbstractContextManager +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +import portalocker +import yaml +from pydantic import BaseModel, ValidationError + +from gewu.application.context import ValidationContext +from gewu.application.paths import reject_symlink_components +from gewu.application.ports import CanonicalConflictError, CanonicalRepository +from gewu.application.serialization import safe_yaml_record +from gewu.domain.contracts import ( + AutoAcceptPolicy, + ExtractionRecord, + KnowledgeNode, + ProjectRecord, + ProjectStatus, + SourceRevisionRecord, +) +from gewu.domain.validation import extraction_digest + +MAX_CONTEXT_RECORDS = 20_000 +MAX_CONTEXT_RECORD_BYTES = 1_048_576 +MAX_PROJECT_REGISTRY_RESPONSE_BYTES = 1_048_576 + + +class RegistryError(RuntimeError): + """Configured or committed context is malformed; acceptance must stop.""" + + +class RegistryConflict(RegistryError): + """A registry write conflicts with current project or policy identity.""" + + +class RegistryNotFound(RegistryError): + """A requested project is not registered.""" + + +def _unique_object(pairs: list[tuple[str, object]]) -> dict[str, object]: + result: dict[str, object] = {} + for key, value in pairs: + if key in result: + raise ValueError("duplicate JSON key") + result[key] = value + return result + + +def _records[RecordT: BaseModel]( + raw: bytes, field: str, record_type: type[RecordT] +) -> tuple[RecordT, ...]: + if len(raw) > MAX_CONTEXT_RECORD_BYTES: + raise RegistryError("Registry configuration exceeds the supported size") + try: + value = json.loads(raw, object_pairs_hook=_unique_object) + if ( + not isinstance(value, dict) + or set(value) != {"schema_version", "projects", "policies"} + or value.get("schema_version") != 1 + ): + raise ValueError("registry schema version or fields are invalid") + records = value[field] + if not isinstance(records, list): + raise ValueError("registry field must be a list") + return tuple( + record_type.model_validate_json(json.dumps(item, ensure_ascii=False), strict=True) + for item in records + ) + except (KeyError, TypeError, ValueError, json.JSONDecodeError, ValidationError) as error: + raise RegistryError("Registry configuration failed validation") from error + + +def _index[RecordT: BaseModel](records: tuple[RecordT, ...], field: str) -> dict[str, RecordT]: + result: dict[str, RecordT] = {} + for record in records: + key = getattr(record, field) + if key in result: + raise RegistryError("Registry contains duplicate stable IDs") + result[key] = record + return result + + +def _canonical_records[RecordT: BaseModel]( + repository: CanonicalRepository, + revision: str, + prefix: str, + record_type: type[RecordT], + id_field: str, +) -> dict[str, RecordT]: + records: dict[str, RecordT] = {} + for record_path in repository.list_files(revision, prefix): + if not record_path.endswith(".yaml"): + continue + if prefix == "projects" and "/nodes/" not in record_path: + continue + raw = repository.read_file(revision, record_path) + if raw is None or len(raw) > MAX_CONTEXT_RECORD_BYTES: + raise RegistryError("Canonical context record is unavailable or too large") + record = safe_yaml_record(raw, record_type) + record_id = getattr(record, id_field) + if record_id in records: + raise RegistryError("Canonical repository contains duplicate stable IDs") + records[record_id] = record + if len(records) > MAX_CONTEXT_RECORDS: + raise RegistryError("Canonical context exceeds the configured record limit") + return records + + +@dataclass(frozen=True, slots=True) +class LocalContextProvider: + repository: CanonicalRepository + registry_file: Path + + def __call__(self) -> ValidationContext: + path = self.registry_file.expanduser().absolute() + try: + reject_symlink_components(path) + except ValueError as error: + raise RegistryError("Local registry path traverses a symbolic link") from error + if path.exists() and not path.is_file(): + raise RegistryError("Local registry file is unavailable") + if not path.exists(): + projects: dict[str, ProjectRecord] = {} + policies: dict[str, AutoAcceptPolicy] = {} + else: + try: + registry_bytes = path.read_bytes() + except OSError as error: + raise RegistryError("Local registry file is unavailable") from error + projects = _index(_records(registry_bytes, "projects", ProjectRecord), "project_id") + policies = _index(_records(registry_bytes, "policies", AutoAcceptPolicy), "policy_id") + + revision = self.repository.head() + projects.update(_canonical_projects(self.repository, revision)) + policies.update( + _canonical_records( + self.repository, + revision, + "policies", + AutoAcceptPolicy, + "policy_id", + ) + ) + sources = _canonical_records( + self.repository, revision, "sources", SourceRevisionRecord, "revision_id" + ) + nodes = _canonical_records(self.repository, revision, "projects", KnowledgeNode, "node_id") + extractions: dict[str, ExtractionRecord] = {} + record_count = 0 + try: + record_count += len(sources) + len(nodes) + for record_path in self.repository.list_files(revision, "extractions"): + if not record_path.endswith(".json"): + continue + record_count += 1 + extraction_bytes = self.repository.read_file(revision, record_path) + if extraction_bytes is None or len(extraction_bytes) > MAX_CONTEXT_RECORD_BYTES: + raise RegistryError("Canonical extraction is unavailable or too large") + extraction = ExtractionRecord.model_validate_json(extraction_bytes, strict=True) + if extraction_digest(extraction) != extraction.extraction_id: + raise RegistryError("Canonical extraction identity is invalid") + if extraction.extraction_id in extractions: + raise RegistryError("Canonical repository contains duplicate extraction IDs") + extractions[extraction.extraction_id] = extraction + except (OSError, ValueError, ValidationError) as error: + raise RegistryError("Canonical context failed validation") from error + if record_count > MAX_CONTEXT_RECORDS: + raise RegistryError("Canonical context exceeds the configured record limit") + return ValidationContext( + projects=projects, + policies=policies, + sources=sources, + extractions=extractions, + existing_nodes=nodes, + ) + + +@dataclass(frozen=True, slots=True) +class LocalProjectRegistry: + """Read legacy control entries and canonical project lifecycle records.""" + + registry_file: Path + repository: CanonicalRepository | None = None + index_directory: Path | None = None + authority_lock_path: Path | None = None + + def _legacy_projects(self) -> tuple[ProjectRecord, ...]: + path = self.registry_file.expanduser().absolute() + try: + reject_symlink_components(path) + except ValueError as error: + raise RegistryError("Local registry path traverses a symbolic link") from error + if not path.exists(): + return () + if path.is_symlink() or not path.is_file(): + raise RegistryError("Local registry file is unavailable") + try: + raw = path.read_bytes() + except OSError as error: + raise RegistryError("Local registry file is unavailable") from error + return _records(raw, "projects", ProjectRecord) + + def _projects(self) -> tuple[ProjectRecord, ...]: + projects = _index(self._legacy_projects(), "project_id") + if self.repository is not None: + projects.update(_canonical_projects(self.repository, self.repository.head())) + return tuple(projects.values()) + + def _policies(self) -> tuple[AutoAcceptPolicy, ...]: + path = self.registry_file.expanduser().absolute() + if not path.exists(): + policies: dict[str, AutoAcceptPolicy] = {} + elif path.is_symlink() or not path.is_file(): + raise RegistryError("Local registry file is unavailable") + else: + try: + raw = path.read_bytes() + except OSError as error: + raise RegistryError("Local registry file is unavailable") from error + policies = _index(_records(raw, "policies", AutoAcceptPolicy), "policy_id") + if self.repository is not None: + policies.update( + _canonical_records( + self.repository, + self.repository.head(), + "policies", + AutoAcceptPolicy, + "policy_id", + ) + ) + return tuple(policies.values()) + + def __call__(self) -> frozenset[str]: + return frozenset(_index(self._projects(), "project_id")) + + def records(self) -> tuple[ProjectRecord, ...]: + records = tuple(sorted(self._projects(), key=lambda project: project.project_id)) + response_bytes = sum( + len( + json.dumps( + project.model_dump(mode="json"), + ensure_ascii=False, + separators=(",", ":"), + ).encode("utf-8") + ) + for project in records + ) + if response_bytes > MAX_PROJECT_REGISTRY_RESPONSE_BYTES: + raise RegistryError("Project registry listing exceeds the supported size") + return records + + def create(self, project: ProjectRecord, policy: AutoAcceptPolicy) -> ProjectRecord: + """Atomically add a project and its explicitly supplied restrictive policy.""" + + if project.project_id == "shared" or project.status != ProjectStatus.ACTIVE: + raise ValueError("New projects must use a non-reserved ID and active status") + if not project.owners: + raise ValueError("At least one named project owner is required") + if ( + project.autonomy_policy_id != policy.policy_id + or policy.project_id != project.project_id + ): + raise ValueError("Project and initial policy identities must match") + if not policy.require_supported_assessment: + raise ValueError("Project policies must require supported assessments") + if ( + self.repository is None + or self.index_directory is None + or self.authority_lock_path is None + ): + raise RegistryError("Canonical project publication is not configured") + with self._write_lock(): + project_index = _index(self._projects(), "project_id") + policy_index = _index(self._policies(), "policy_id") + if project.project_id in project_index: + raise RegistryConflict("Project ID is already registered") + if policy.policy_id in policy_index: + raise RegistryConflict("Policy ID is already registered") + available_projects = set(project_index) | {project.project_id, "shared"} + if any(item not in available_projects for item in project.default_includes): + raise ValueError("Project default includes must reference registered projects") + if project.project_id in project.default_includes: + raise ValueError("A project cannot include itself by default") + try: + self.repository.publish_files( + expected_head=self.repository.head(), + files={ + _project_record_path(project.project_id): _project_markdown(project), + _policy_record_path(policy.policy_id): _yaml_record(policy), + }, + message=f"Register project {project.project_id}", + index_directory=self.index_directory, + ) + except CanonicalConflictError as error: + raise RegistryConflict( + "Canonical project registry changed during creation" + ) from error + return project + + def update_content( + self, project_id: str, *, description: str, north_star: str, key_questions: tuple[str, ...] + ) -> ProjectRecord: + """Update a project's human-authored description and research questions.""" + if self.repository is None or self.index_directory is None or self.authority_lock_path is None: + raise RegistryError("Canonical project publication is not configured") + with self._write_lock(): + project = _index(self._projects(), "project_id").get(project_id) + if project is None: + raise RegistryNotFound("Project is not registered") + updated = project.model_copy(update={ + "description": description, "north_star": north_star, "key_questions": key_questions, + }) + try: + self.repository.publish_files( + expected_head=self.repository.head(), + files={_project_record_path(project_id): _project_markdown(updated)}, + message=f"Update project description and questions for {project_id}", + index_directory=self.index_directory, + ) + except CanonicalConflictError as error: + raise RegistryConflict("Canonical project registry changed during update") from error + return updated + + def archive(self, project_id: str) -> ProjectRecord: + """Mark a project archived while preserving its registry and read history.""" + + if project_id == "shared": + raise ValueError("The shared scope cannot be archived") + if ( + self.repository is None + or self.index_directory is None + or self.authority_lock_path is None + ): + raise RegistryError("Canonical project publication is not configured") + with self._write_lock(): + project_index = _index(self._projects(), "project_id") + project = project_index.get(project_id) + if project is None: + raise RegistryNotFound("Project is not registered") + if project.status == ProjectStatus.ARCHIVED: + return project + archived = project.model_copy(update={"status": ProjectStatus.ARCHIVED}) + try: + self.repository.publish_files( + expected_head=self.repository.head(), + files={_project_record_path(project_id): _project_markdown(archived)}, + message=f"Archive project {project_id}", + index_directory=self.index_directory, + ) + except CanonicalConflictError as error: + raise RegistryConflict( + "Canonical project registry changed during archive" + ) from error + return archived + + def _write_lock(self) -> AbstractContextManager[Any]: + lock_path = self.authority_lock_path + if lock_path is None: + lock_path = self.registry_file.with_suffix(self.registry_file.suffix + ".lock") + lock_path = lock_path.expanduser().absolute() + reject_symlink_components(lock_path) + lock_path.parent.mkdir(parents=True, exist_ok=True, mode=0o700) + lock_path.parent.chmod(0o700) + return portalocker.Lock(lock_path, mode="a", timeout=0) + + def default_includes(self, project_id: str) -> tuple[str, ...]: + projects = _index(self._projects(), "project_id") + project = projects.get(project_id) + if project is None: + raise RegistryError("Project is unavailable for default scope resolution") + return tuple(include for include in project.default_includes if include != "shared") + + +def _project_record_path(project_id: str) -> str: + return f"projects/{project_id}/project.md" + + +def _policy_record_path(policy_id: str) -> str: + import hashlib + + digest = hashlib.sha256(policy_id.encode("utf-8")).hexdigest() + return f"policies/{digest}.yaml" + + +def _yaml_record(record: BaseModel) -> bytes: + return yaml.safe_dump( + record.model_dump(mode="json"), + allow_unicode=True, + sort_keys=True, + ).encode("utf-8") + + +def _project_markdown(project: ProjectRecord) -> bytes: + return b"---\n" + _yaml_record(project) + b"---\n" + + +def _canonical_projects(repository: CanonicalRepository, revision: str) -> dict[str, ProjectRecord]: + projects: dict[str, ProjectRecord] = {} + total_bytes = 0 + for record_path in repository.list_files(revision, "projects"): + if not record_path.endswith("/project.md"): + continue + raw = repository.read_file(revision, record_path) + if raw is None or len(raw) > MAX_CONTEXT_RECORD_BYTES: + raise RegistryError("Canonical project record is unavailable or too large") + total_bytes += len(raw) + if total_bytes > MAX_PROJECT_REGISTRY_RESPONSE_BYTES: + raise RegistryError("Canonical project registry exceeds the supported size") + if not raw.startswith(b"---\n") or b"\n---\n" not in raw[4:]: + raise RegistryError("Canonical project frontmatter is invalid") + frontmatter = raw[4:].split(b"\n---\n", maxsplit=1)[0] + project = safe_yaml_record(frontmatter, ProjectRecord) + if record_path != _project_record_path(project.project_id): + raise RegistryError("Canonical project path does not match its stable ID") + if project.project_id in projects: + raise RegistryError("Canonical repository contains duplicate project IDs") + projects[project.project_id] = project + if len(projects) > MAX_CONTEXT_RECORDS: + raise RegistryError("Canonical project registry exceeds the supported record count") + return projects diff --git a/src/gewu/application/runs.py b/src/gewu/application/runs.py new file mode 100644 index 0000000..c97adce --- /dev/null +++ b/src/gewu/application/runs.py @@ -0,0 +1,223 @@ +"""Private durable run checkpoints scoped to an authenticated agent identity.""" + +from __future__ import annotations + +import hashlib +import os +import secrets +import stat +import threading +from collections.abc import Callable, Iterator +from contextlib import contextmanager +from datetime import UTC, datetime +from pathlib import Path + +import portalocker +from pydantic import BaseModel, ConfigDict, ValidationError + +from gewu.application.acceptance import ActorContext +from gewu.domain.contracts import ( + RunCheckpointRequest, + RunCreateRequest, + RunRecord, + RunState, + Sha256, +) +from gewu.domain.validation import canonical_json + + +class RunError(RuntimeError): + """Safe run API failure with a stable transport code.""" + + def __init__(self, code: str, message: str, status_code: int = 400) -> None: + super().__init__(message) + self.code = code + self.status_code = status_code + + +class _StoredRun(BaseModel): + model_config = ConfigDict(extra="forbid", strict=True, frozen=True) + + owner_id: str + create_digest: Sha256 + record: RunRecord + + +def _digest(value: object) -> str: + return hashlib.sha256(canonical_json(value)).hexdigest() + + +class LocalRunService: + """Atomic checkpoint store with stable owner/run IDs and revision CAS updates.""" + + MAX_RECORD_BYTES = 1_000_000 + + def __init__(self, *, workflow_root: Path, actor_is_current: Callable[[ActorContext], bool]): + self.root = workflow_root.expanduser().absolute() / "runs" + self.root.mkdir(parents=True, exist_ok=True, mode=0o700) + if self.root.is_symlink() or not self.root.is_dir(): + raise RuntimeError("GEWU run directory must be a real directory") + self.root.chmod(stat.S_IRWXU) + self.lock_path = self.root.parent / "locks" / "canonical-writer.lock" + self.lock_path.parent.mkdir(parents=True, exist_ok=True, mode=0o700) + if self.lock_path.parent.is_symlink() or not self.lock_path.parent.is_dir(): + raise RuntimeError("GEWU run lock directory must be a real directory") + self.lock_path.parent.chmod(stat.S_IRWXU) + self.actor_is_current = actor_is_current + self._thread_lock = threading.RLock() + + def create(self, request: RunCreateRequest, *, actor: ActorContext) -> RunRecord: + if not self.actor_is_current(actor): + raise RunError("UNAUTHORIZED", "Actor credential is no longer active", 401) + if request.scope.all_projects: + raise RunError("SCOPE_REQUIRED", "Runs require an explicit project scope", 422) + create_digest = _digest(request.model_dump(mode="json")) + path = self._record_path(actor.principal_id, request.run_id) + initial = RunRecord( + run_id=request.run_id, + revision=0, + state=RunState.ACTIVE, + objective=request.objective, + scope=request.scope, + lens=request.lens, + pack_id=request.pack_id, + pack_version=request.pack_version, + pack_snapshot_digest=request.pack_snapshot_digest, + module_pins=request.module_pins, + stage="context", + created_at=datetime.now(UTC), + updated_at=datetime.now(UTC), + ) + with self._writer_lock(): + if not self.actor_is_current(actor): + raise RunError("UNAUTHORIZED", "Actor credential is no longer active", 401) + if path.is_symlink(): + raise RunError("RUN_RECOVERY_REQUIRED", "Run path is unavailable", 503) + if path.exists(): + current = self._read(path, actor) + stored = self._read_stored(path) + if stored.create_digest == create_digest: + return current + raise RunError("IDEMPOTENCY_CONFLICT", "Run ID already binds different input", 409) + self._write( + path, + _StoredRun( + owner_id=actor.principal_id, + create_digest=create_digest, + record=initial, + ), + ) + return initial + + def status(self, run_id: str, *, actor: ActorContext) -> RunRecord: + if not self.actor_is_current(actor): + raise RunError("UNAUTHORIZED", "Actor credential is no longer active", 401) + return self._read(self._record_path(actor.principal_id, run_id), actor) + + def checkpoint(self, request: RunCheckpointRequest, *, actor: ActorContext) -> RunRecord: + if not self.actor_is_current(actor): + raise RunError("UNAUTHORIZED", "Actor credential is no longer active", 401) + checkpoint_digest = _digest(request.model_dump(mode="json")) + path = self._record_path(actor.principal_id, request.run_id) + with self._writer_lock(): + if not self.actor_is_current(actor): + raise RunError("UNAUTHORIZED", "Actor credential is no longer active", 401) + current = self._read(path, actor) + if current.revision == request.expected_revision + 1: + if current.checkpoint_digest == checkpoint_digest: + return current + raise RunError("RUN_REVISION_CONFLICT", "Run checkpoint retry changed content", 409) + if current.revision != request.expected_revision: + raise RunError("RUN_REVISION_CONFLICT", "Run checkpoint revision is stale", 409) + if current.state is not RunState.ACTIVE: + raise RunError("RUN_CLOSED", "Completed or blocked runs cannot be updated", 409) + updated = current.model_copy( + update={ + "revision": current.revision + 1, + "state": request.state, + "stage": request.stage, + "sources": request.sources, + "coverage": request.coverage, + "budgets": request.budgets, + "pending_operations": request.pending_operations, + "proposals": request.proposals, + "output_hashes": request.output_hashes, + "updated_at": datetime.now(UTC), + "checkpoint_digest": checkpoint_digest, + } + ) + self._write( + path, + _StoredRun( + owner_id=actor.principal_id, + create_digest=self._read_stored(path).create_digest, + record=updated, + ), + ) + return updated + + def _read(self, path: Path, actor: ActorContext) -> RunRecord: + stored = self._read_stored(path) + if stored.owner_id != actor.principal_id: + raise RunError("RUN_NOT_FOUND", "Run is unavailable", 404) + return stored.record + + @staticmethod + def _read_stored(path: Path) -> _StoredRun: + if path.is_symlink() or not path.is_file() or path.stat().st_size > 1_000_000: + raise RunError("RUN_NOT_FOUND", "Run is unavailable", 404) + try: + return _StoredRun.model_validate_json(path.read_bytes(), strict=True) + except (OSError, ValueError, ValidationError) as error: + raise RunError("RUN_RECOVERY_REQUIRED", "Run checkpoint is unavailable", 503) from error + + def _record_path(self, principal_id: str, run_id: str) -> Path: + if not run_id or len(run_id) > 128: + raise RunError("RUN_NOT_FOUND", "Run is unavailable", 404) + owner_key = hashlib.sha256(principal_id.encode("utf-8")).hexdigest() + run_key = hashlib.sha256(run_id.encode("utf-8")).hexdigest() + directory = self.root / owner_key + if directory.is_symlink(): + raise RunError("RUN_RECOVERY_REQUIRED", "Run directory is unavailable", 503) + if directory.exists() and not directory.is_dir(): + raise RunError("RUN_RECOVERY_REQUIRED", "Run directory is unavailable", 503) + return directory / f"{run_key}.json" + + def _write(self, path: Path, stored: _StoredRun) -> None: + content = stored.model_dump_json().encode("utf-8") + if len(content) > self.MAX_RECORD_BYTES: + raise RunError("RUN_TOO_LARGE", "Run checkpoint exceeds the one-megabyte limit", 413) + path.parent.mkdir(parents=True, exist_ok=True, mode=0o700) + if path.parent.is_symlink() or not path.parent.is_dir(): + raise RunError("RUN_STORE_UNAVAILABLE", "Run directory is unavailable", 503) + path.parent.chmod(stat.S_IRWXU) + temporary = path.with_name(f".{path.name}.{secrets.token_hex(8)}.tmp") + try: + descriptor = os.open(temporary, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600) + with os.fdopen(descriptor, "wb") as stream: + stream.write(content) + stream.flush() + os.fsync(stream.fileno()) + os.replace(temporary, path) + if os.name != "nt": + path.chmod(stat.S_IRUSR | stat.S_IWUSR) + directory_fd = os.open(path.parent, os.O_RDONLY) + try: + os.fsync(directory_fd) + finally: + os.close(directory_fd) + except OSError as error: + raise RunError( + "RUN_STORE_UNAVAILABLE", "Run checkpoint could not be saved", 503 + ) from error + finally: + temporary.unlink(missing_ok=True) + + @contextmanager + def _writer_lock(self) -> Iterator[None]: + with self._thread_lock: + try: + with portalocker.Lock(self.lock_path, mode="a", timeout=0): + yield + except portalocker.exceptions.LockException as error: + raise RunError("BUSY", "Run store is handling another operation", 409) from error diff --git a/src/gewu/application/search.py b/src/gewu/application/search.py new file mode 100644 index 0000000..6ecd315 --- /dev/null +++ b/src/gewu/application/search.py @@ -0,0 +1,998 @@ +"""Scope and freshness policy for derived canonical search.""" + +from __future__ import annotations + +import base64 +import hashlib +import json +import secrets +import sqlite3 +from collections import defaultdict +from collections.abc import Callable, Collection, Iterator +from dataclasses import dataclass +from datetime import UTC, date, datetime +from typing import Literal, Protocol + +from gewu.application.identity import Principal +from gewu.application.ports import EmbeddingProvider, VectorGeneration, VectorIndex +from gewu.domain.contracts import ( + BudgetProfile, + Confidence, + EdgeEndpoint, + EvidenceReference, + Finding, + KnowledgeNode, + NeighborRequest, + ReadEnvelope, + Scope, + SearchRequest, + SourceListRequest, + SourceRevisionRecord, + TypedEdge, +) +from gewu.domain.validation import canonical_json, evidence_digest + + +class SearchError(RuntimeError): + """Safe search failure with an HTTP-compatible status.""" + + def __init__(self, status_code: int, message: str) -> None: + super().__init__(message) + self.status_code = status_code + + +@dataclass(frozen=True, slots=True) +class IndexedNode: + node: KnowledgeNode + score: float + expanded_from: str | None = None + expansion_edge: TypedEdge | None = None + + +@dataclass(frozen=True, slots=True) +class SearchIndexResult: + index_revision: str + nodes: tuple[IndexedNode, ...] + has_more: bool = False + retrieval_mode: Literal["keyword", "hybrid"] = "keyword" + warnings: tuple[Finding, ...] = () + + +@dataclass(frozen=True, slots=True) +class SourceIndexResult: + index_revision: str + sources: tuple[SourceRevisionRecord, ...] + has_more: bool = False + + +@dataclass(frozen=True, slots=True) +class SearchEdgeResult: + index_revision: str + origin: KnowledgeNode | None + edges: tuple[SearchEdgeHit, ...] + has_more: bool = False + + +@dataclass(frozen=True, slots=True) +class SearchEdgeHit: + edge: TypedEdge + neighbor_endpoint: EdgeEndpoint + neighbor_node: KnowledgeNode | None + neighbor_source: SourceRevisionRecord | None + + +class SearchIndex(Protocol): + def diagnostic_status(self) -> tuple[str | None, int, bool]: ... + + def search( + self, + *, + query: str, + project_ids: tuple[str, ...], + include_shared: bool, + limit: int, + node_types: tuple[str, ...] = (), + tags: tuple[str, ...] = (), + audience: tuple[str, ...] = (), + use_for: tuple[str, ...] = (), + domain: tuple[str, ...] = (), + confidences: tuple[Confidence, ...] = (), + as_of_from: date | None = None, + as_of_to: date | None = None, + stale_only: bool = False, + candidate_node_ids: tuple[str, ...] | None = None, + offset: int = 0, + ) -> SearchIndexResult: ... + + def neighbors( + self, + *, + node_id: str, + edge_types: tuple[str, ...], + project_ids: tuple[str, ...], + include_shared: bool, + limit: int, + offset: int = 0, + ) -> SearchEdgeResult: ... + + def list_sources( + self, + *, + project_ids: tuple[str, ...], + include_shared: bool, + limit: int, + offset: int = 0, + ) -> SourceIndexResult: ... + + +_VECTOR_CANDIDATE_LIMIT = 100 +_VECTOR_BUILD_BATCH_SIZE = 32 +_GRAPH_SEED_LIMIT = 10 +_GRAPH_EDGE_LIMIT = 50 +_GRAPH_EXPANSION_SCORE = 1 / 160.5 +_NODE_CONTENT_PROFILE = "knowledge-node-title-summary-claims-tags-v1" + + +class HybridSearchIndex: + """Fuse scoped lexical and vector candidates over a pinned vector generation.""" + + def __init__( + self, + *, + lexical: SearchIndex, + vectors: VectorIndex, + embeddings: EmbeddingProvider, + ) -> None: + self.lexical = lexical + self.vectors = vectors + self.embeddings = embeddings + self._failed_profile: tuple[str, str] | None = None + + def diagnostic_status(self) -> tuple[str | None, int, bool]: + return self.lexical.diagnostic_status() + + def cursor_profile(self) -> str: + return hashlib.sha256( + "\0".join( + ( + "hybrid-rrf-v1", + "typed-graph-one-hop-v1", + str(_VECTOR_CANDIDATE_LIMIT), + str(_GRAPH_SEED_LIMIT), + str(_GRAPH_EDGE_LIMIT), + self.embeddings.model_id, + self.embeddings.artifact_sha256, + str(self.embeddings.dimensions), + _NODE_CONTENT_PROFILE, + ) + ).encode() + ).hexdigest() + + def search( + self, + *, + query: str, + project_ids: tuple[str, ...], + include_shared: bool, + limit: int, + node_types: tuple[str, ...] = (), + tags: tuple[str, ...] = (), + audience: tuple[str, ...] = (), + use_for: tuple[str, ...] = (), + domain: tuple[str, ...] = (), + confidences: tuple[Confidence, ...] = (), + as_of_from: date | None = None, + as_of_to: date | None = None, + stale_only: bool = False, + candidate_node_ids: tuple[str, ...] | None = None, + offset: int = 0, + ) -> SearchIndexResult: + if not query.strip() or candidate_node_ids is not None: + return self.lexical.search( + query=query, + project_ids=project_ids, + include_shared=include_shared, + limit=limit, + node_types=node_types, + tags=tags, + audience=audience, + use_for=use_for, + domain=domain, + confidences=confidences, + as_of_from=as_of_from, + as_of_to=as_of_to, + stale_only=stale_only, + candidate_node_ids=candidate_node_ids, + offset=offset, + ) + try: + lexical_head = self.lexical.search( + query=query, + project_ids=project_ids, + include_shared=include_shared, + limit=_VECTOR_CANDIDATE_LIMIT, + node_types=node_types, + tags=tags, + audience=audience, + use_for=use_for, + domain=domain, + confidences=confidences, + as_of_from=as_of_from, + as_of_to=as_of_to, + stale_only=stale_only, + ) + generation = self.vectors.active_generation() + if not self._matches_generation(generation, lexical_head.index_revision): + raise RuntimeError("Vector generation is unavailable") + assert generation is not None + query_vector = self.embeddings.embed_queries((query,))[0] + vector_hits = self.vectors.search( + query=query_vector, + project_ids=project_ids, + include_shared=include_shared, + limit=_VECTOR_CANDIDATE_LIMIT, + expected_generation_id=generation.generation_id, + ) + vector_nodes = self.lexical.search( + query="", + project_ids=project_ids, + include_shared=include_shared, + limit=_VECTOR_CANDIDATE_LIMIT, + node_types=node_types, + tags=tags, + audience=audience, + use_for=use_for, + domain=domain, + confidences=confidences, + as_of_from=as_of_from, + as_of_to=as_of_to, + stale_only=stale_only, + candidate_node_ids=tuple(hit.node_id for hit in vector_hits), + ) + vector_node_by_id = {hit.node.node_id: hit.node for hit in vector_nodes.nodes} + fused_scores: defaultdict[str, float] = defaultdict(float) + nodes_by_id: dict[str, KnowledgeNode] = {} + for rank, hit in enumerate(lexical_head.nodes, start=1): + fused_scores[hit.node.node_id] += 1 / (60 + rank) + nodes_by_id[hit.node.node_id] = hit.node + for rank, vector_hit in enumerate(vector_hits, start=1): + node = vector_node_by_id.get(vector_hit.node_id) + if node is not None: + fused_scores[vector_hit.node_id] += 1 / (60 + rank) + nodes_by_id[vector_hit.node_id] = node + ranked = sorted( + fused_scores, + key=lambda node_id: (-fused_scores[node_id], node_id), + ) + graph_candidates: list[tuple[str, TypedEdge, KnowledgeNode]] = [] + graph_node_ids: set[str] = set() + fused_node_ids = set(ranked) + examined_edge_count = 0 + for seed_id in ranked[:_GRAPH_SEED_LIMIT]: + remaining = _GRAPH_EDGE_LIMIT - examined_edge_count + if remaining <= 0: + break + neighbors = self.lexical.neighbors( + node_id=seed_id, + edge_types=(), + project_ids=project_ids, + include_shared=include_shared, + limit=remaining, + ) + examined_edge_count += len(neighbors.edges) + for neighbor in neighbors.edges: + node = neighbor.neighbor_node + if ( + node is None + or node.node_id in fused_node_ids + or node.node_id in graph_node_ids + ): + continue + graph_node_ids.add(node.node_id) + graph_candidates.append((seed_id, neighbor.edge, node)) + if graph_candidates: + eligible_graph_nodes = self.lexical.search( + query="", + project_ids=project_ids, + include_shared=include_shared, + limit=len(graph_candidates), + node_types=node_types, + tags=tags, + audience=audience, + use_for=use_for, + domain=domain, + confidences=confidences, + as_of_from=as_of_from, + as_of_to=as_of_to, + stale_only=stale_only, + candidate_node_ids=tuple(node.node_id for _, _, node in graph_candidates), + ) + eligible_ids = {hit.node.node_id for hit in eligible_graph_nodes.nodes} + expanded_nodes = tuple( + IndexedNode( + node=node, + score=_GRAPH_EXPANSION_SCORE, + expanded_from=seed_id, + expansion_edge=edge, + ) + for seed_id, edge, node in graph_candidates + if node.node_id in eligible_ids + ) + else: + expanded_nodes = () + fixed_head = tuple( + [IndexedNode(nodes_by_id[node_id], fused_scores[node_id]) for node_id in ranked] + + list(expanded_nodes) + ) + head_start = min(offset, len(fixed_head)) + head_page = [*fixed_head[head_start : offset + limit]] + tail_offset = max(0, offset - len(fixed_head)) + tail_limit = limit - len(head_page) + lexical_tail = SearchIndexResult(lexical_head.index_revision, ()) + if lexical_head.has_more and tail_limit > 0: + lexical_tail = self.lexical.search( + query=query, + project_ids=project_ids, + include_shared=include_shared, + limit=tail_limit, + node_types=node_types, + tags=tags, + audience=audience, + use_for=use_for, + domain=domain, + confidences=confidences, + as_of_from=as_of_from, + as_of_to=as_of_to, + stale_only=stale_only, + offset=_VECTOR_CANDIDATE_LIMIT + tail_offset, + ) + lexical_tail = SearchIndexResult( + index_revision=lexical_tail.index_revision, + nodes=tuple( + IndexedNode( + hit.node, + 1 / (60 + _VECTOR_CANDIDATE_LIMIT + tail_offset + rank), + ) + for rank, hit in enumerate(lexical_tail.nodes, start=1) + ), + has_more=lexical_tail.has_more, + ) + page = (*head_page, *lexical_tail.nodes) + has_more = ( + offset + len(head_page) < len(fixed_head) + or (tail_limit == 0 and lexical_head.has_more) + or (tail_limit > 0 and lexical_tail.has_more) + ) + return SearchIndexResult( + index_revision=lexical_head.index_revision, + nodes=page, + has_more=has_more, + retrieval_mode="hybrid", + ) + except (OSError, RuntimeError, ValueError, IndexError): + warning = Finding( + code="HYBRID_RETRIEVAL_DEGRADED", + message=( + "Vector retrieval is unavailable for this index revision; " + "keyword results are shown." + ), + ) + fallback = self.lexical.search( + query=query, + project_ids=project_ids, + include_shared=include_shared, + limit=limit, + node_types=node_types, + tags=tags, + audience=audience, + use_for=use_for, + domain=domain, + confidences=confidences, + as_of_from=as_of_from, + as_of_to=as_of_to, + stale_only=stale_only, + offset=offset, + ) + return SearchIndexResult( + index_revision=fallback.index_revision, + nodes=fallback.nodes, + has_more=fallback.has_more, + warnings=(warning,), + ) + + def vector_generation_is_current(self, revision: str) -> bool: + active = self.vectors.active_generation() + return self._matches_generation(active, revision) or self._failed_profile == ( + revision, + self.cursor_profile(), + ) + + def build_vector_generation( + self, + connection: sqlite3.Connection, + revision: str, + nodes: tuple[KnowledgeNode, ...], + ) -> None: + """Stage vectors and their active pointer inside the canonical index transaction.""" + + ordered_nodes = tuple(sorted(nodes, key=lambda node: (node.project_id, node.node_id))) + generation_id = self._generation_id(revision) + active = self.vectors.active_generation_in_transaction(connection) + generation = self.vectors.generation_in_transaction(connection, generation_id) + if not self._matches_generation(generation, revision): + connection.execute("SAVEPOINT vector_generation_stage") + try: + if generation is None: + self.vectors.build_generation_in_transaction( + connection, + generation_id=generation_id, + revision=revision, + model_id=self.embeddings.model_id, + artifact_sha256=self.embeddings.artifact_sha256, + content_profile=_NODE_CONTENT_PROFILE, + dimensions=self.embeddings.dimensions, + vectors=self._vector_records(ordered_nodes), + ) + generation = self.vectors.generation_in_transaction(connection, generation_id) + if not self._matches_generation(generation, revision): + raise RuntimeError("Vector generation metadata does not match the model") + self.vectors.activate_in_transaction( + connection, + generation_id, + expected_active=active.generation_id if active is not None else None, + ) + connection.execute("RELEASE SAVEPOINT vector_generation_stage") + self._failed_profile = None + except (RuntimeError, ValueError, sqlite3.Error): + connection.execute("ROLLBACK TO SAVEPOINT vector_generation_stage") + connection.execute("RELEASE SAVEPOINT vector_generation_stage") + self.vectors.deactivate_in_transaction(connection) + self._failed_profile = (revision, self.cursor_profile()) + return + elif active is None or active.generation_id != generation_id: + self.vectors.activate_in_transaction( + connection, + generation_id, + expected_active=active.generation_id if active is not None else None, + ) + self._failed_profile = None + + def _generation_id(self, revision: str) -> str: + return hashlib.sha256( + "\0".join( + ( + revision, + self.embeddings.model_id, + self.embeddings.artifact_sha256, + str(self.embeddings.dimensions), + _NODE_CONTENT_PROFILE, + ) + ).encode() + ).hexdigest() + + def _vector_records( + self, nodes: tuple[KnowledgeNode, ...] + ) -> Iterator[tuple[str, str, tuple[float, ...]]]: + for start in range(0, len(nodes), _VECTOR_BUILD_BATCH_SIZE): + batch = nodes[start : start + _VECTOR_BUILD_BATCH_SIZE] + texts = tuple(self._node_text(node) for node in batch) + embeddings = self.embeddings.embed_passages(texts) + yield from ( + (node.project_id, node.node_id, vector) + for node, vector in zip(batch, embeddings, strict=True) + ) + + def _matches_generation(self, generation: VectorGeneration | None, revision: str) -> bool: + return bool( + generation is not None + and generation.revision == revision + and generation.model_id == self.embeddings.model_id + and generation.artifact_sha256 == self.embeddings.artifact_sha256 + and generation.content_profile == _NODE_CONTENT_PROFILE + and generation.dimensions == self.embeddings.dimensions + ) + + @staticmethod + def _node_text(node: KnowledgeNode) -> str: + return "\n".join( + ( + node.title, + node.summary, + *(claim.text for claim in node.evidence_claims), + *node.tags, + *node.aliases, + ) + ) + + def neighbors( + self, + *, + node_id: str, + edge_types: tuple[str, ...], + project_ids: tuple[str, ...], + include_shared: bool, + limit: int, + offset: int = 0, + ) -> SearchEdgeResult: + return self.lexical.neighbors( + node_id=node_id, + edge_types=edge_types, + project_ids=project_ids, + include_shared=include_shared, + limit=limit, + offset=offset, + ) + + def list_sources( + self, + *, + project_ids: tuple[str, ...], + include_shared: bool, + limit: int, + offset: int = 0, + ) -> SourceIndexResult: + return self.lexical.list_sources( + project_ids=project_ids, + include_shared=include_shared, + limit=limit, + offset=offset, + ) + + +def _budgeted_envelope( + *, + request_id: str, + scope: Scope, + rows: list[dict[str, object]], + citation_groups: list[dict[str, EvidenceReference]], + warnings: list[Finding], + canonical_revision: str, + index_revision: str, + budget_tokens: int, + retrieval_mode: Literal["keyword", "hybrid", "canonical"], + next_cursor_for: Callable[[int], str | None] | None = None, +) -> ReadEnvelope: + for included_count in range(len(rows), -1, -1): + evidence: dict[str, EvidenceReference] = {} + for group in citation_groups[:included_count]: + evidence.update(group) + omitted = len(rows) - included_count + omissions = ( + ( + Finding( + code="BUDGET_TRUNCATED", + message=f"Omitted {omitted} lower-ranked result(s) to fit the budget.", + ), + ) + if omitted + else () + ) + envelope = ReadEnvelope( + request_id=request_id, + resolved_scope=scope, + data=tuple(rows[:included_count]), + citations=tuple(evidence[key] for key in sorted(evidence)), + warnings=tuple(warnings), + omissions=omissions, + next_cursor=next_cursor_for(included_count) if next_cursor_for is not None else None, + canonical_revision=canonical_revision, + index_revision=index_revision, + retrieval_mode=retrieval_mode, + budget_profile=BudgetProfile( + tokenizer="unicode-codepoints-v1", encoding="canonical-json-v1" + ), + tokens_used=0, + ) + envelope = CanonicalSearchService._count_complete_payload(envelope) + if envelope.tokens_used <= budget_tokens: + if len(canonical_json(envelope.model_dump(mode="json"))) > 1_000_000: + raise SearchError(413, "Search response exceeds the one-megabyte response budget") + return envelope + raise SearchError(413, "Search budget is too small for the required response envelope") + + +class CanonicalSearchService: + """Authorize explicit project scope, then search a rebuildable index.""" + + def __init__( + self, + *, + index: SearchIndex, + project_ids_provider: Callable[[], Collection[str]], + canonical_head: Callable[[], str], + default_includes_provider: Callable[[str], tuple[str, ...]] | None = None, + ) -> None: + self.index = index + self.project_ids_provider = project_ids_provider + self.canonical_head = canonical_head + self.default_includes_provider = default_includes_provider + + def diagnostic_index_status(self) -> tuple[str, str | None, str, int | None]: + canonical_revision = self.canonical_head() + try: + index_revision, schema_version, available = self.index.diagnostic_status() + except (OSError, RuntimeError): + return canonical_revision, None, "unavailable", None + if not available: + return canonical_revision, None, "unavailable", schema_version + if index_revision is None: + return canonical_revision, None, "not_built", schema_version + state = "current" if index_revision == canonical_revision else "lagging" + return canonical_revision, index_revision, state, schema_version + + def search(self, request: SearchRequest, *, principal: Principal) -> ReadEnvelope: + project_ids = self._resolve_scope(request.scope, principal) + fingerprint = hashlib.sha256( + canonical_json( + { + "query": request.query, + "projects": project_ids, + "include_shared": request.scope.include_shared, + "node_types": tuple(sorted(item.value for item in request.node_types)), + "tags": tuple(sorted(request.tags)), + "audience": tuple(sorted(request.audience)), + "use_for": tuple(sorted(request.use_for)), + "domain": tuple(sorted(request.domain)), + "confidences": tuple(sorted(item.value for item in request.confidences)), + "as_of_from": request.as_of_from.isoformat() if request.as_of_from else None, + "as_of_to": request.as_of_to.isoformat() if request.as_of_to else None, + "stale_only": request.stale_only, + "limit": request.limit, + "retrieval_profile": ( + self.index.cursor_profile() + if isinstance(self.index, HybridSearchIndex) + else "keyword-v1" + ), + } + ) + ).hexdigest() + offset, expected_index_revision = self._cursor_offset(request.cursor, fingerprint) + + result = self.index.search( + query=request.query, + project_ids=project_ids, + include_shared=request.scope.include_shared, + node_types=tuple(item.value for item in request.node_types), + tags=request.tags, + audience=request.audience, + use_for=request.use_for, + domain=request.domain, + confidences=request.confidences, + as_of_from=request.as_of_from, + as_of_to=request.as_of_to, + stale_only=request.stale_only, + limit=request.limit, + offset=offset, + ) + if expected_index_revision is not None and expected_index_revision != result.index_revision: + raise SearchError(409, "Search cursor expired because the index revision changed") + + canonical_revision = self.canonical_head() + warnings: list[Finding] = list(result.warnings) + now = datetime.now(UTC) + evidence_by_node: list[dict[str, EvidenceReference]] = [] + rows: list[dict[str, object]] = [] + for hit in result.nodes: + node = hit.node + row: dict[str, object] = { + "node": node.model_dump(mode="json"), + "retrieval_score": hit.score, + } + if hit.expanded_from is not None and hit.expansion_edge is not None: + row["retrieval_path"] = "graph_expansion" + row["expanded_from"] = hit.expanded_from + row["expansion_edge"] = hit.expansion_edge.model_dump(mode="json") + rows.append(row) + if node.stale_after is not None and node.stale_after <= now: + warnings.append( + Finding( + code="STALE_NODE", + message=f"Knowledge node {node.node_id} is past its freshness date.", + path=node.node_id, + ) + ) + node_evidence: dict[str, EvidenceReference] = {} + for claim in node.evidence_claims: + for item in claim.evidence: + key = evidence_digest(item) + node_evidence[key] = item + if hit.expansion_edge is not None: + for item in hit.expansion_edge.evidence: + key = evidence_digest(item) + node_evidence[key] = item + evidence_by_node.append(node_evidence) + if result.index_revision != canonical_revision: + warnings.append( + Finding( + code="INDEX_LAG", + message=( + "Search results reflect an older accepted revision; " + "canonical reads remain available." + ), + ) + ) + return _budgeted_envelope( + request_id="search-" + secrets.token_hex(12), + scope=Scope(projects=project_ids, include_shared=request.scope.include_shared), + rows=rows, + citation_groups=evidence_by_node, + warnings=warnings, + canonical_revision=canonical_revision, + index_revision=result.index_revision, + budget_tokens=request.budget_tokens, + retrieval_mode=result.retrieval_mode, + next_cursor_for=( + lambda included: ( + ( + self._encode_cursor(offset + included, fingerprint, result.index_revision) + if result.has_more or included < len(rows) + else None + ) + if not result.warnings + else None + ) + ), + ) + + @staticmethod + def _encode_cursor(offset: int, fingerprint: str, index_revision: str) -> str: + payload = canonical_json( + {"offset": offset, "fingerprint": fingerprint, "index_revision": index_revision} + ) + return base64.urlsafe_b64encode(payload).decode("ascii").rstrip("=") + + @staticmethod + def _cursor_offset( + cursor: str | None, fingerprint: str, *, max_offset: int = 100_000 + ) -> tuple[int, str | None]: + if cursor is None: + return 0, None + try: + padded = cursor + "=" * (-len(cursor) % 4) + payload = json.loads(base64.b64decode(padded, altchars=b"-_", validate=True)) + offset = payload["offset"] + if ( + not isinstance(payload, dict) + or set(payload) != {"offset", "fingerprint", "index_revision"} + or type(offset) is not int + or not 0 <= offset <= max_offset + or payload["fingerprint"] != fingerprint + or not isinstance(payload["index_revision"], str) + or len(payload["index_revision"]) not in {40, 64} + or any( + character not in "0123456789abcdef" for character in payload["index_revision"] + ) + ): + raise ValueError("cursor does not match this search") + return offset, payload["index_revision"] + except (ValueError, KeyError, TypeError, json.JSONDecodeError) as error: + raise SearchError(422, "Search cursor is invalid for this query and scope") from error + + def list_projects(self, *, principal: Principal) -> tuple[str, ...]: + """List only projects visible to the authenticated principal.""" + + return tuple( + sorted( + project_id + for project_id in set(self.project_ids_provider()) + if principal.can_read_project(project_id) + ) + ) + + def _resolve_scope(self, scope: Scope, principal: Principal) -> tuple[str, ...]: + registered_projects = set(self.project_ids_provider()) + if scope.all_projects: + if "admin" not in principal.roles: + raise SearchError(403, "All-project search requires an administrator") + project_ids = tuple(sorted(registered_projects)) + else: + if scope.include_projects is not None: + includes = scope.include_projects + elif len(scope.projects) == 1 and self.default_includes_provider is not None: + includes = self.default_includes_provider(scope.projects[0]) + else: + includes = () + includes = tuple(project_id for project_id in includes if project_id != "shared") + project_ids = tuple(dict.fromkeys((*scope.projects, *includes))) + unknown = set(project_ids) - registered_projects - {"shared"} + if unknown: + raise SearchError(404, "Search scope contains an unknown project") + if not project_ids: + raise SearchError(422, "Search scope resolved to no projects") + if any(not principal.can_read_project(project_id) for project_id in project_ids): + raise SearchError(403, "Credential cannot read every requested project") + if scope.include_shared and not principal.can_read_project("shared"): + raise SearchError(403, "Credential cannot read shared knowledge") + return project_ids + + def neighbors(self, request: NeighborRequest, *, principal: Principal) -> ReadEnvelope: + project_ids = self._resolve_scope(request.scope, principal) + fingerprint = hashlib.sha256( + canonical_json( + { + "node_id": request.node_id, + "projects": project_ids, + "include_shared": request.scope.include_shared, + "edge_types": request.edge_types, + "limit": request.limit, + } + ) + ).hexdigest() + offset, expected_index_revision = self._cursor_offset( + request.cursor, fingerprint, max_offset=500_000 + ) + result = self.index.neighbors( + node_id=request.node_id, + edge_types=tuple(request.edge_types), + project_ids=project_ids, + include_shared=request.scope.include_shared, + limit=request.limit, + offset=offset, + ) + if expected_index_revision is not None and expected_index_revision != result.index_revision: + raise SearchError(409, "Neighbor cursor expired because the index revision changed") + origin = result.origin + allowed = set(project_ids) + if request.scope.include_shared: + allowed.add("shared") + if origin is None or origin.project_id not in allowed: + raise SearchError(404, "Knowledge node was not found within the requested scope") + + canonical_revision = self.canonical_head() + warnings: list[Finding] = [] + stale_ids: set[str] = set() + if origin.stale_after is not None and origin.stale_after <= datetime.now(UTC): + stale_ids.add(origin.node_id) + rows: list[dict[str, object]] = [] + citation_groups: list[dict[str, EvidenceReference]] = [] + for hit in result.edges: + endpoint = hit.neighbor_endpoint + if endpoint.kind == "node": + neighbor = hit.neighbor_node + if neighbor is None or neighbor.project_id not in allowed: + continue + neighbor_value: dict[str, object] = { + "kind": "node", + "id": endpoint.id, + "record": neighbor.model_dump(mode="json"), + } + if neighbor.stale_after is not None and neighbor.stale_after <= datetime.now(UTC): + stale_ids.add(neighbor.node_id) + elif endpoint.kind == "project": + if endpoint.id not in allowed: + continue + neighbor_value = {"kind": "project", "id": endpoint.id} + neighbor = None + else: + source = hit.neighbor_source + if source is None or not (set(source.ingested_for) & allowed): + continue + neighbor_value = { + "kind": "source", + "id": endpoint.id, + "record": source.model_dump(mode="json"), + } + neighbor = None + rows.append( + { + "from_node": { + "node_id": origin.node_id, + "project_id": origin.project_id, + "title": origin.title, + }, + "edge": hit.edge.model_dump(mode="json"), + "neighbor": neighbor_value, + } + ) + group: dict[str, EvidenceReference] = {} + for item in hit.edge.evidence: + group[evidence_digest(item)] = item + if neighbor is not None: + for claim in neighbor.claims: + for item in claim.evidence: + group[evidence_digest(item)] = item + citation_groups.append(group) + warnings.extend( + Finding( + code="STALE_NODE", + message=f"Knowledge node {node_id} is past its freshness date.", + path=node_id, + ) + for node_id in sorted(stale_ids) + ) + if result.index_revision != canonical_revision: + warnings.append( + Finding( + code="INDEX_LAG", + message=( + "Neighbor results reflect an older accepted revision; " + "canonical reads remain available." + ), + ) + ) + return _budgeted_envelope( + request_id="neighbors-" + secrets.token_hex(12), + scope=Scope(projects=project_ids, include_shared=request.scope.include_shared), + rows=rows, + citation_groups=citation_groups, + warnings=warnings, + canonical_revision=canonical_revision, + index_revision=result.index_revision, + budget_tokens=request.budget_tokens, + retrieval_mode="keyword", + next_cursor_for=( + lambda included: ( + self._encode_cursor(offset + included, fingerprint, result.index_revision) + if result.has_more or included < len(rows) + else None + ) + ), + ) + + def list_sources(self, request: SourceListRequest, *, principal: Principal) -> ReadEnvelope: + project_ids = self._resolve_scope(request.scope, principal) + fingerprint = hashlib.sha256( + canonical_json( + { + "projects": project_ids, + "include_shared": request.scope.include_shared, + "limit": request.limit, + } + ) + ).hexdigest() + offset, expected_index_revision = self._cursor_offset( + request.cursor, fingerprint, max_offset=500_000 + ) + result = self.index.list_sources( + project_ids=project_ids, + include_shared=request.scope.include_shared, + limit=request.limit, + offset=offset, + ) + if expected_index_revision is not None and expected_index_revision != result.index_revision: + raise SearchError(409, "Source cursor expired because the index revision changed") + + canonical_revision = self.canonical_head() + rows: list[dict[str, object]] = [ + {"source": source.model_dump(mode="json")} for source in result.sources + ] + warnings: list[Finding] = [] + if result.index_revision != canonical_revision: + warnings.append( + Finding( + code="INDEX_LAG", + message=( + "Source results reflect an older accepted revision; " + "canonical source reads remain available." + ), + ) + ) + return _budgeted_envelope( + request_id="sources-" + secrets.token_hex(12), + scope=Scope(projects=project_ids, include_shared=request.scope.include_shared), + rows=rows, + citation_groups=[{} for _ in rows], + warnings=warnings, + canonical_revision=canonical_revision, + index_revision=result.index_revision, + budget_tokens=request.budget_tokens, + retrieval_mode="keyword", + next_cursor_for=( + lambda included: ( + self._encode_cursor(offset + included, fingerprint, result.index_revision) + if result.has_more or included < len(rows) + else None + ) + ), + ) + + @staticmethod + def _count_complete_payload(envelope: ReadEnvelope) -> ReadEnvelope: + counted = envelope + for _ in range(4): + serialized = canonical_json(counted.model_dump(mode="json")) + count = len(serialized.decode("utf-8")) + if count == counted.tokens_used: + return counted + counted = counted.model_copy(update={"tokens_used": count}) + return counted diff --git a/src/gewu/application/serialization.py b/src/gewu/application/serialization.py new file mode 100644 index 0000000..5f04faa --- /dev/null +++ b/src/gewu/application/serialization.py @@ -0,0 +1,35 @@ +"""Strict serializers for locally persisted typed application records.""" + +from __future__ import annotations + +import json + +import yaml +from pydantic import BaseModel + + +def safe_yaml_record[RecordT: BaseModel](raw: bytes, expected_type: type[RecordT]) -> RecordT: + """Parse YAML without custom tags or duplicate keys, then validate strictly.""" + + class UniqueKeyLoader(yaml.SafeLoader): + pass + + def construct_mapping( + loader: yaml.SafeLoader, node: yaml.MappingNode, deep: bool = False + ) -> dict[object, object]: + keys: set[object] = set() + mapping: dict[object, object] = {} + for key_node, value_node in node.value: + key = loader.construct_object(key_node, deep=deep) + if key in keys: + raise ValueError("duplicate YAML key") + keys.add(key) + mapping[key] = loader.construct_object(value_node, deep=deep) + return mapping + + UniqueKeyLoader.add_constructor( + yaml.resolver.BaseResolver.DEFAULT_MAPPING_TAG, + construct_mapping, + ) + value = yaml.load(raw, Loader=UniqueKeyLoader) + return expected_type.model_validate_json(json.dumps(value, ensure_ascii=False), strict=True) diff --git a/src/gewu/application/uploads.py b/src/gewu/application/uploads.py new file mode 100644 index 0000000..a401dd5 --- /dev/null +++ b/src/gewu/application/uploads.py @@ -0,0 +1,652 @@ +"""Bounded, resumable local upload and content-addressed artifact storage.""" + +from __future__ import annotations + +import codecs +import hashlib +import json +import os +import re +import secrets +import stat +import threading +import time +from collections.abc import Iterator +from contextlib import contextmanager +from dataclasses import dataclass +from pathlib import Path +from typing import BinaryIO + +import portalocker + +from gewu.application.paths import reject_symlink_components +from gewu.domain.contracts import ExtractionRecord, Sha256 +from gewu.domain.validation import extraction_digest + +MAX_ARTIFACT_BYTES = 50 * 1024 * 1024 +MAX_CAPTURE_RECEIPTS = 1024 +CHUNK_BYTES = 512 * 1024 +IDLE_EXPIRY_SECONDS = 10 * 60 +ALLOWED_MEDIA_TYPES = { + "application/pdf", + "application/x-subrip", + "text/html", + "text/markdown", + "text/plain", + "text/vtt", +} +ALLOWED_SUFFIXES = {".htm", ".html", ".txt", ".md", ".markdown", ".pdf", ".srt", ".vtt"} +_SAFE_UPLOAD_ID = re.compile(r"^[A-Za-z0-9_-]{24,64}$") + + +class UploadError(ValueError): + """Safe client-facing upload failure with a stable error code.""" + + def __init__(self, code: str, message: str) -> None: + super().__init__(message) + self.code = code + + +@dataclass(frozen=True, slots=True) +class UploadSession: + upload_id: str + verified_offsets: tuple[int, ...] + chunk_bytes: int + max_artifact_bytes: int + expires_at: int + + +@dataclass(frozen=True, slots=True) +class ArtifactReceipt: + sha256: Sha256 + byte_length: int + media_type: str + status: str = "staged" + + +def _sha256_file(path: Path) -> str: + digest = hashlib.sha256() + with path.open("rb") as stream: + while block := stream.read(1024 * 1024): + digest.update(block) + return digest.hexdigest() + + +def _write_json_atomically(path: Path, payload: dict[str, object]) -> None: + temporary = path.with_name(f".{path.name}.{secrets.token_hex(8)}.tmp") + data = json.dumps(payload, sort_keys=True, separators=(",", ":")).encode("utf-8") + try: + with temporary.open("xb") as stream: + stream.write(data) + stream.flush() + os.fsync(stream.fileno()) + os.replace(temporary, path) + finally: + temporary.unlink(missing_ok=True) + + +class LocalArtifactStore: + """Filesystem adapter. Roots are service-owned and never model-controlled.""" + + def __init__(self, data_root: Path) -> None: + self.root = data_root.expanduser().absolute() + reject_symlink_components(self.root) + self.upload_root = self.root / "staging" / "uploads" + self.artifact_root = self.root / "artifacts" / "sha256" + self.extraction_root = self.root / "extractions" / "sha256" + self.capture_receipt_root = self.root / "staging" / "capture-receipts" + self._lock = threading.RLock() + for directory in ( + self.upload_root, + self.artifact_root, + self.extraction_root, + self.capture_receipt_root, + ): + directory.mkdir(parents=True, exist_ok=True, mode=0o700) + if directory.is_symlink() or not directory.is_dir(): + raise RuntimeError("GEWU data directory must be a real directory") + directory.chmod(stat.S_IRWXU) + + @contextmanager + def capture_receipt_guard(self) -> Iterator[None]: + """Serialize the bounded text-capture idempotency journal across workers.""" + + lock_path = self.capture_receipt_root / ".capture.lock" + reject_symlink_components(lock_path) + with portalocker.Lock(lock_path, mode="a", timeout=30): + yield + + def get_capture_receipt( + self, + capture_id: str, + *, + owner_id: str, + project_id: str, + request_sha256: str, + ) -> dict[str, object] | None: + path = self._capture_receipt_path(capture_id, owner_id) + if path.is_symlink() or not path.exists(): + return None + try: + value = json.loads(path.read_bytes()) + except (OSError, ValueError) as error: + raise UploadError( + "CAPTURE_STATE_INVALID", "Pasted-text capture state is invalid" + ) from error + if ( + not isinstance(value, dict) + or value.get("capture_id") != capture_id + or value.get("owner_id") != owner_id + ): + raise UploadError("CAPTURE_STATE_INVALID", "Pasted-text capture state is invalid") + if value.get("project_id") != project_id or value.get("request_sha256") != request_sha256: + raise UploadError("CAPTURE_CONFLICT", "Capture ID was already used for different input") + result = value.get("result") + if not isinstance(result, dict): + raise UploadError("CAPTURE_STATE_INVALID", "Pasted-text capture state is invalid") + path.touch(exist_ok=True) + os.utime(path, None) + return result + + def save_capture_receipt( + self, + capture_id: str, + *, + owner_id: str, + project_id: str, + request_sha256: str, + result: dict[str, object], + ) -> None: + path = self._capture_receipt_path(capture_id, owner_id) + existing = self.get_capture_receipt( + capture_id, + owner_id=owner_id, + project_id=project_id, + request_sha256=request_sha256, + ) + if existing is not None: + if existing != result: + raise UploadError("CAPTURE_CONFLICT", "Capture ID already has a different result") + return + _write_json_atomically( + path, + { + "capture_id": capture_id, + "owner_id": owner_id, + "project_id": project_id, + "request_sha256": request_sha256, + "result": result, + }, + ) + receipts = sorted( + (item for item in self.capture_receipt_root.glob("*.json") if item != path), + key=lambda item: item.stat().st_mtime_ns, + ) + for expired in receipts[: max(0, len(receipts) + 1 - MAX_CAPTURE_RECEIPTS)]: + expired.unlink(missing_ok=True) + + def _capture_receipt_path(self, capture_id: str, owner_id: str) -> Path: + key = hashlib.sha256(f"{owner_id}\0{capture_id}".encode()).hexdigest() + return self.capture_receipt_root / f"{key}.json" + + def begin( + self, + *, + filename: str, + media_type: str, + byte_length: int, + expected_sha256: str, + owner_id: str | None = None, + project_id: str | None = None, + now: int | None = None, + ) -> UploadSession: + suffix = Path(filename).suffix.lower() + expected_media = { + ".pdf": "application/pdf", + ".srt": "application/x-subrip", + ".vtt": "text/vtt", + ".html": "text/html", + ".htm": "text/html", + ".md": "text/markdown", + ".markdown": "text/markdown", + ".txt": "text/plain", + } + if suffix not in ALLOWED_SUFFIXES or expected_media.get(suffix) != media_type: + raise UploadError( + "UNSUPPORTED_MEDIA_TYPE", "Only PDF and UTF-8 HTML, text and Markdown are supported" + ) + if byte_length < 1 or byte_length > MAX_ARTIFACT_BYTES: + raise UploadError("UPLOAD_TOO_LARGE", "Artifact size is outside the supported limit") + if not re.fullmatch(r"[a-f0-9]{64}", expected_sha256): + raise UploadError( + "INVALID_DIGEST", "Expected SHA-256 must be 64 lowercase hex characters" + ) + created = int(time.time()) if now is None else now + upload_id = secrets.token_urlsafe(24) + directory = self.upload_root / upload_id + with self._lock: + directory.mkdir(mode=0o700) + metadata: dict[str, object] = { + "upload_id": upload_id, + "media_type": media_type, + "byte_length": byte_length, + "expected_sha256": expected_sha256, + "owner_id": owner_id, + "project_id": project_id, + "created_at": created, + "updated_at": created, + "suffix": suffix, + "chunk_sha256": {}, + } + _write_json_atomically(directory / "session.json", metadata) + return UploadSession( + upload_id, (), CHUNK_BYTES, MAX_ARTIFACT_BYTES, created + IDLE_EXPIRY_SECONDS + ) + + def _session_directory(self, upload_id: str) -> Path: + if not _SAFE_UPLOAD_ID.fullmatch(upload_id): + raise UploadError("UPLOAD_NOT_FOUND", "Upload session does not exist") + directory = self.upload_root / upload_id + if directory.is_symlink() or not directory.is_dir(): + raise UploadError("UPLOAD_NOT_FOUND", "Upload session does not exist") + return directory + + def _load_session(self, directory: Path) -> dict[str, object]: + metadata_path = directory / "session.json" + if metadata_path.is_symlink() or not metadata_path.is_file(): + raise UploadError("UPLOAD_NOT_FOUND", "Upload session does not exist") + try: + data = json.loads(metadata_path.read_text(encoding="utf-8")) + except (OSError, UnicodeError, json.JSONDecodeError) as error: + raise UploadError("UPLOAD_CORRUPT", "Upload session metadata is unavailable") from error + if not isinstance(data, dict): + raise UploadError("UPLOAD_CORRUPT", "Upload session metadata is unavailable") + return data + + def _check_active(self, metadata: dict[str, object], now: int) -> None: + updated_at = metadata.get("updated_at") + if not isinstance(updated_at, int) or now - updated_at > IDLE_EXPIRY_SECONDS: + raise UploadError("UPLOAD_EXPIRED", "Upload session expired; start a new upload") + + @staticmethod + def _check_owner(metadata: dict[str, object], owner_id: str | None) -> None: + if metadata.get("owner_id") != owner_id: + raise UploadError("UPLOAD_NOT_FOUND", "Upload session does not exist") + + def _byte_length(self, metadata: dict[str, object]) -> int: + value = metadata.get("byte_length") + if not isinstance(value, int) or value < 1 or value > MAX_ARTIFACT_BYTES: + raise UploadError("UPLOAD_CORRUPT", "Upload session metadata is unavailable") + return value + + def verified_offsets( + self, upload_id: str, *, owner_id: str | None = None, now: int | None = None + ) -> tuple[int, ...]: + current = int(time.time()) if now is None else now + with self._lock: + directory = self._session_directory(upload_id) + metadata = self._load_session(directory) + self._check_owner(metadata, owner_id) + self._check_active(metadata, current) + byte_length = self._byte_length(metadata) + offsets: list[int] = [] + for offset in range(0, byte_length, CHUNK_BYTES): + chunk_path = directory / f"{offset:020d}.chunk" + if chunk_path.is_symlink() or not chunk_path.is_file(): + break + expected_length = min(CHUNK_BYTES, byte_length - offset) + if chunk_path.stat().st_size != expected_length: + break + chunk_digests = metadata.get("chunk_sha256") + expected_digest = ( + chunk_digests.get(str(offset)) if isinstance(chunk_digests, dict) else None + ) + if not isinstance(expected_digest, str) or not secrets.compare_digest( + _sha256_file(chunk_path), expected_digest + ): + break + offsets.append(offset) + return tuple(offsets) + + def put_chunk( + self, + upload_id: str, + *, + offset: int, + content: bytes, + chunk_sha256: str, + owner_id: str | None = None, + now: int | None = None, + ) -> tuple[int, ...]: + current = int(time.time()) if now is None else now + if not content or len(content) > CHUNK_BYTES: + raise UploadError("CHUNK_SIZE_INVALID", "Chunk must contain 1 to 512 KiB") + actual_digest = hashlib.sha256(content).hexdigest() + if not secrets.compare_digest(actual_digest, chunk_sha256): + raise UploadError("CHUNK_DIGEST_MISMATCH", "Chunk SHA-256 did not match its bytes") + with self._lock: + directory = self._session_directory(upload_id) + metadata = self._load_session(directory) + self._check_owner(metadata, owner_id) + self._check_active(metadata, current) + byte_length = self._byte_length(metadata) + if offset < 0 or offset % CHUNK_BYTES or offset >= byte_length: + raise UploadError( + "CHUNK_OFFSET_INVALID", "Chunk offset is outside the upload sequence" + ) + required_length = min(CHUNK_BYTES, byte_length - offset) + if len(content) != required_length: + raise UploadError( + "CHUNK_SIZE_INVALID", "Chunk length does not match its upload position" + ) + + offsets = self.verified_offsets(upload_id, owner_id=owner_id, now=current) + chunk_path = directory / f"{offset:020d}.chunk" + if offset in offsets: + if not secrets.compare_digest(_sha256_file(chunk_path), actual_digest): + raise UploadError( + "IDEMPOTENCY_CONFLICT", "Chunk offset already contains different bytes" + ) + return offsets + if offset != len(offsets) * CHUNK_BYTES: + raise UploadError("CHUNK_OFFSET_INVALID", "Chunks must be uploaded in order") + with chunk_path.open("xb") as stream: + stream.write(content) + stream.flush() + os.fsync(stream.fileno()) + chunk_digests = metadata.get("chunk_sha256") + if not isinstance(chunk_digests, dict): + raise UploadError("UPLOAD_CORRUPT", "Upload session metadata is unavailable") + chunk_digests[str(offset)] = actual_digest + metadata["chunk_sha256"] = chunk_digests + metadata["updated_at"] = current + _write_json_atomically(directory / "session.json", metadata) + return offsets + (offset,) + + def finalize( + self, upload_id: str, *, owner_id: str | None = None, now: int | None = None + ) -> ArtifactReceipt: + current = int(time.time()) if now is None else now + with self._lock: + directory = self._session_directory(upload_id) + metadata = self._load_session(directory) + self._check_owner(metadata, owner_id) + self._check_active(metadata, current) + byte_length = self._byte_length(metadata) + expected_sha256 = metadata.get("expected_sha256") + if not isinstance(expected_sha256, str) or not re.fullmatch( + r"[a-f0-9]{64}", expected_sha256 + ): + raise UploadError("UPLOAD_CORRUPT", "Upload session metadata is unavailable") + offsets = self.verified_offsets(upload_id, owner_id=owner_id, now=current) + expected_offsets = tuple(range(0, byte_length, CHUNK_BYTES)) + if offsets != expected_offsets: + raise UploadError("UPLOAD_INCOMPLETE", "Upload is missing one or more chunks") + + temporary = directory / f".{secrets.token_hex(12)}.artifact" + digest = hashlib.sha256() + media_type = metadata.get("media_type") + is_pdf = media_type == "application/pdf" + decoder = None if is_pdf else codecs.getincrementaldecoder("utf-8")("strict") + header = bytearray() + try: + with temporary.open("xb") as output: + for offset in expected_offsets: + chunk_path = directory / f"{offset:020d}.chunk" + if chunk_path.is_symlink(): + raise UploadError("UPLOAD_CORRUPT", "Upload chunk is unavailable") + with chunk_path.open("rb") as source: + while block := source.read(64 * 1024): + digest.update(block) + if len(header) < 5: + header.extend(block[: 5 - len(header)]) + if decoder is not None: + try: + decoder.decode(block) + except UnicodeDecodeError as error: + raise UploadError( + "TEXT_ENCODING_INVALID", "Text artifacts must use UTF-8" + ) from error + output.write(block) + if decoder is not None: + try: + decoder.decode(b"", final=True) + except UnicodeDecodeError as error: + raise UploadError( + "TEXT_ENCODING_INVALID", "Text artifacts must use UTF-8" + ) from error + if is_pdf and bytes(header) != b"%PDF-": + raise UploadError("PDF_INVALID", "PDF artifact signature is invalid") + output.flush() + os.fsync(output.fileno()) + + if digest.hexdigest() != expected_sha256: + raise UploadError( + "ARTIFACT_DIGEST_MISMATCH", "Artifact SHA-256 did not match upload" + ) + artifact_digest = digest.hexdigest() + destination_directory = self.artifact_root / artifact_digest[:2] + destination_directory.mkdir(exist_ok=True, mode=0o700) + if destination_directory.is_symlink(): + raise UploadError( + "ARTIFACT_STORE_UNAVAILABLE", "Artifact storage is unavailable" + ) + destination = destination_directory / artifact_digest + if destination.exists(): + if destination.is_symlink() or destination.stat().st_size != byte_length: + raise UploadError( + "ARTIFACT_STORE_CONFLICT", "Stored artifact failed integrity checks" + ) + if not secrets.compare_digest(_sha256_file(destination), artifact_digest): + raise UploadError( + "ARTIFACT_STORE_CONFLICT", "Stored artifact failed integrity checks" + ) + temporary.unlink() + else: + os.replace(temporary, destination) + artifact_owner_id = metadata.get("owner_id") + project_id = metadata.get("project_id") + if isinstance(artifact_owner_id, str) and isinstance(project_id, str): + provenance = destination.with_suffix(".access.json") + grants: dict[str, object] = {} + if provenance.exists(): + try: + loaded = json.loads(provenance.read_text(encoding="utf-8")) + if isinstance(loaded, dict): + grants = { + key: value + for key, value in loaded.items() + if isinstance(key, str) + and isinstance(value, list) + and all(isinstance(item, str) for item in value) + } + except (OSError, UnicodeError, json.JSONDecodeError): + raise UploadError( + "ARTIFACT_STORE_CONFLICT", "Stored artifact access data is invalid" + ) from None + existing_projects = grants.get(artifact_owner_id, []) + projects = ( + set(existing_projects) + if isinstance(existing_projects, list) + and all(isinstance(item, str) for item in existing_projects) + else set() + ) + projects.add(project_id) + grants[artifact_owner_id] = sorted(projects) + grants["media_type"] = metadata.get("media_type") + _write_json_atomically(provenance, grants) + for path in directory.iterdir(): + if path.is_file() and not path.is_symlink(): + path.unlink() + directory.rmdir() + return ArtifactReceipt( + sha256=artifact_digest, + byte_length=byte_length, + media_type=str(metadata.get("media_type", "application/octet-stream")), + ) + finally: + temporary.unlink(missing_ok=True) + + def open_artifact( + self, + sha256: str, + *, + owner_id: str | None = None, + project_id: str | None = None, + ) -> BinaryIO: + if not re.fullmatch(r"[a-f0-9]{64}", sha256): + raise UploadError("ARTIFACT_NOT_FOUND", "Artifact does not exist") from None + path = self.artifact_root / sha256[:2] / sha256 + if ( + path.is_symlink() + or not path.is_file() + or not secrets.compare_digest(_sha256_file(path), sha256) + ): + raise UploadError("ARTIFACT_NOT_FOUND", "Artifact does not exist") + if owner_id is not None or project_id is not None: + access_path = path.with_suffix(".access.json") + try: + if access_path.is_symlink() or not access_path.is_file(): + raise OSError("artifact access metadata is unavailable") + access = json.loads(access_path.read_text(encoding="utf-8")) + projects = access.get(owner_id) if isinstance(access, dict) and owner_id else None + if owner_id is None and project_id is not None and isinstance(access, dict): + projects = [ + granted_project + for owner, owner_projects in access.items() + if owner != "media_type" and isinstance(owner_projects, list) + for granted_project in owner_projects + ] + except (OSError, UnicodeError, json.JSONDecodeError): + projects = None + if not isinstance(projects, list) or project_id not in projects: + raise UploadError("ARTIFACT_NOT_FOUND", "Artifact does not exist") + return path.open("rb") + + def artifact_media_type_for_project(self, sha256: str, *, project_id: str) -> str: + if not re.fullmatch(r"[a-f0-9]{64}", sha256): + raise UploadError("ARTIFACT_NOT_FOUND", "Artifact does not exist") + path = self.artifact_root / sha256[:2] / f"{sha256}.access.json" + try: + if path.is_symlink() or not path.is_file(): + raise OSError("artifact access metadata is unavailable") + access = json.loads(path.read_text(encoding="utf-8")) + grants = ( + [ + project + for owner, projects in access.items() + if owner != "media_type" and isinstance(projects, list) + for project in projects + ] + if isinstance(access, dict) + else [] + ) + media_type = access.get("media_type") if isinstance(access, dict) else None + except (OSError, UnicodeError, json.JSONDecodeError): + raise UploadError("ARTIFACT_NOT_FOUND", "Artifact does not exist") from None + if project_id not in grants or not isinstance(media_type, str): + raise UploadError("ARTIFACT_NOT_FOUND", "Artifact does not exist") + return media_type + + def artifact_media_type(self, sha256: str, *, owner_id: str, project_id: str) -> str: + path = self.artifact_root / sha256[:2] / f"{sha256}.access.json" + try: + access = json.loads(path.read_text(encoding="utf-8")) + projects = access.get(owner_id) if isinstance(access, dict) else None + media_type = access.get("media_type") if isinstance(access, dict) else None + except (OSError, UnicodeError, json.JSONDecodeError): + raise UploadError("ARTIFACT_NOT_FOUND", "Artifact does not exist") from None + if ( + not isinstance(projects, list) + or project_id not in projects + or not isinstance(media_type, str) + ): + raise UploadError("ARTIFACT_NOT_FOUND", "Artifact does not exist") + return media_type + + def save_extraction( + self, + extraction: ExtractionRecord, + *, + owner_id: str | None = None, + project_id: str | None = None, + ) -> None: + if extraction_digest(extraction) != extraction.extraction_id: + raise UploadError("EXTRACTION_HASH_MISMATCH", "Extraction identity is invalid") + digest = extraction.extraction_id + directory = self.extraction_root / digest[:2] + directory.mkdir(exist_ok=True, mode=0o700) + if directory.is_symlink(): + raise UploadError("EXTRACTION_STORE_UNAVAILABLE", "Extraction storage is unavailable") + path = directory / f"{digest}.json" + with self._lock: + stored: dict[str, object] = { + "owner_id": owner_id, + "project_ids": [project_id] if project_id is not None else [], + "record": extraction.model_dump(mode="json"), + } + if path.exists(): + if path.is_symlink(): + raise UploadError("EXTRACTION_STORE_CONFLICT", "Stored extraction is invalid") + try: + existing = json.loads(path.read_bytes()) + except (OSError, ValueError) as error: + raise UploadError( + "EXTRACTION_STORE_CONFLICT", "Stored extraction is invalid" + ) from error + if ( + not isinstance(existing, dict) + or existing.get("owner_id") != owner_id + or existing.get("record") != stored["record"] + ): + raise UploadError("EXTRACTION_STORE_CONFLICT", "Stored extraction is invalid") + project_ids = existing.get("project_ids", []) + if not isinstance(project_ids, list) or not all( + isinstance(item, str) for item in project_ids + ): + project_ids = [] + if project_id is not None and project_id not in project_ids: + project_ids.append(project_id) + stored["project_ids"] = sorted(set(project_ids)) + content = json.dumps( + stored, + ensure_ascii=False, + allow_nan=False, + sort_keys=True, + separators=(",", ":"), + ).encode("utf-8") + _write_json_atomically(path, json.loads(content)) + + def get_extraction( + self, + extraction_id: str, + *, + owner_id: str | None = None, + project_id: str | None = None, + ) -> ExtractionRecord | None: + if not re.fullmatch(r"[a-f0-9]{64}", extraction_id): + return None + path = self.extraction_root / extraction_id[:2] / f"{extraction_id}.json" + if path.is_symlink() or not path.is_file(): + return None + try: + stored = json.loads(path.read_bytes()) + if ( + not isinstance(stored, dict) + or (owner_id is not None and stored.get("owner_id") != owner_id) + or (owner_id is None and project_id is None) + or ( + project_id is not None + and project_id not in stored.get("project_ids", []) + and stored.get("project_id") != project_id + ) + ): + return None + extraction = ExtractionRecord.model_validate_json( + json.dumps(stored.get("record"), ensure_ascii=False), strict=True + ) + except (OSError, ValueError): + return None + if extraction_digest(extraction) != extraction_id: + return None + return extraction diff --git a/src/gewu/bootstrap/__init__.py b/src/gewu/bootstrap/__init__.py new file mode 100644 index 0000000..fa62b88 --- /dev/null +++ b/src/gewu/bootstrap/__init__.py @@ -0,0 +1 @@ +"""Composition root.""" diff --git a/src/gewu/bootstrap/app.py b/src/gewu/bootstrap/app.py new file mode 100644 index 0000000..2f30ba8 --- /dev/null +++ b/src/gewu/bootstrap/app.py @@ -0,0 +1,955 @@ +"""FastAPI composition root, health check and authenticated canonical reads.""" + +import os +from collections.abc import AsyncIterator, Callable, Iterator +from contextlib import AsyncExitStack, asynccontextmanager +from pathlib import Path +from typing import Annotated, cast + +import portalocker +from fastapi import Cookie, FastAPI, Header, HTTPException, Query, Request, Response +from mcp.server.transport_security import TransportSecuritySettings +from starlette.responses import StreamingResponse +from starlette.routing import Mount + +from gewu.adapters.embeddings import LocalSentenceTransformer +from gewu.adapters.git_repository import GitCanonicalRepository +from gewu.adapters.source_extractor import LocalSourceExtractor, SourceExtractor +from gewu.adapters.sqlite_identity import SQLiteIdentityStore +from gewu.adapters.sqlite_search import SQLiteSearchIndex +from gewu.adapters.sqlite_vectors import SQLiteVectorIndex +from gewu.adapters.url_fetch import PublicUrlFetcher +from gewu.application.acceptance import AcceptanceService +from gewu.application.accounts import AccountIdentity +from gewu.application.admin import CredentialAdministration +from gewu.application.context import ValidationContext +from gewu.application.diagnostics import build_service_diagnostics +from gewu.application.identity import IdentityProvider, OwnerIdentityProvider, Principal +from gewu.application.paths import reject_symlink_components +from gewu.application.ports import ProjectRegistry +from gewu.application.reading import CanonicalNodeReader, CanonicalSourceReader, ReadError +from gewu.application.registry import ( + LocalContextProvider, + LocalProjectRegistry, + RegistryConflict, + RegistryError, + RegistryNotFound, +) +from gewu.application.search import ( + CanonicalSearchService, + HybridSearchIndex, + SearchError, + SearchIndex, +) +from gewu.application.uploads import LocalArtifactStore, UploadError +from gewu.domain.contracts import ( + GitCommitId, + ProjectRecord, + ReadEnvelope, + RecordId, + ServiceDiagnostics, +) +from gewu.transports.accounts_http import register_account_routes +from gewu.transports.admin_http import register_admin_routes +from gewu.transports.http_middleware import ProjectRegistryBodyLimitMiddleware +from gewu.transports.http_models import ( + NeighborRequestBody, + OwnerSessionRequestBody, + ProjectContentUpdateBody, + ProjectCreateBody, + SearchRequestBody, + SourceListRequestBody, +) +from gewu.transports.mcp_server import create_mcp_server +from gewu.transports.owner_mcp_server import OwnerReviewProvider, create_owner_mcp_server +from gewu.version import SERVICE_VERSION + + +def create_app( + *, + identity_provider: IdentityProvider | None = None, + owner_identity_provider: OwnerIdentityProvider | None = None, + owner_service_audience: str | None = None, + node_reader: CanonicalNodeReader | None = None, + artifact_store: LocalArtifactStore | None = None, + extractor: SourceExtractor | None = None, + acceptance_service: AcceptanceService | None = None, + context_provider: Callable[[], ValidationContext] | None = None, + search_service: CanonicalSearchService | None = None, + project_registry: ProjectRegistry | None = None, + credential_administration: CredentialAdministration | None = None, + account_identity: AccountIdentity | None = None, + url_fetcher: PublicUrlFetcher | None = None, +) -> FastAPI: + if (identity_provider is None) != (node_reader is None): + raise ValueError("Identity provider and canonical reader must be configured together") + app = FastAPI(title="GEWU", version=SERVICE_VERSION) + mcp_allowed_hosts = [ + host.strip() + for host in os.environ.get("GEWU_MCP_ALLOWED_HOSTS", "").split(",") + if host.strip() + ] + if ( + project_registry is not None + or owner_identity_provider is not None + or credential_administration is not None + or account_identity is not None + ): + app.add_middleware(ProjectRegistryBodyLimitMiddleware) + + @app.get("/health", tags=["health"], operation_id="health") + def health() -> dict[str, str]: + return {"status": "ok", "service": "gewu"} + + if identity_provider is not None and node_reader is not None: + + def authenticate( + authorization: str | None, + session_cookie: str | None = None, + csrf_token: str | None = None, + *, + require_csrf: bool = False, + ) -> Principal: + scheme, _, token = (authorization or "").partition(" ") + if scheme.lower() == "bearer" and token: + principal = identity_provider.authenticate(token) + if principal is None: + raise HTTPException(status_code=401, detail="Credential is invalid or revoked") + return principal + if session_cookie: + principal = identity_provider.authenticate_browser_session(session_cookie) + if principal is None: + raise HTTPException( + status_code=401, detail="Browser session is invalid or expired" + ) + if require_csrf and not identity_provider.browser_csrf_valid( + session_cookie, csrf_token or "" + ): + raise HTTPException(status_code=403, detail="Browser session CSRF check failed") + return principal + raise HTTPException( + status_code=401, detail="Bearer credential or browser session required" + ) + + def require_same_origin(request: Request) -> None: + origin = request.headers.get("origin") + expected = f"{request.url.scheme}://{request.headers.get('host', '')}" + if origin is not None and origin.rstrip("/") != expected.rstrip("/"): + raise HTTPException( + status_code=403, detail="Browser session requires a same-origin request" + ) + + def require_admin(principal: Principal) -> None: + if "admin" not in principal.roles or "agent" in principal.roles: + raise HTTPException(status_code=403, detail="Administrator role is required") + + if credential_administration is not None: + register_admin_routes(app, credential_administration, authenticate, require_same_origin) + + if account_identity is not None: + register_account_routes(app, account_identity, authenticate, require_same_origin) + + if owner_identity_provider is not None: + + @app.post( + "/auth/owner-session", + tags=["identity"], + operation_id="owner_session_create", + ) + def create_owner_session( + body: OwnerSessionRequestBody, + request: Request, + response: Response, + ) -> dict[str, str]: + require_same_origin(request) + response.headers["Cache-Control"] = "no-store" + host = request.url.hostname or "" + if request.url.scheme != "https" and host not in { + "localhost", + "127.0.0.1", + "::1", + }: + raise HTTPException( + status_code=403, detail="Owner authentication requires HTTPS" + ) + audience = owner_service_audience + if not audience or not owner_identity_provider.owner_authentication_enabled: + raise HTTPException(status_code=503, detail="Owner authentication is disabled") + owner_session = owner_identity_provider.authenticate_owner( + principal_id=body.principal_id, + password=body.password, + totp_code=body.totp_code, + challenge_id=body.challenge_id, + audience=audience, + ) + if owner_session is None: + raise HTTPException(status_code=401, detail="Owner authentication failed") + return { + "session_token": owner_session.session_token, + "challenge_id": owner_session.challenge_id, + "expires_at": owner_session.expires_at.isoformat(), + } + + @app.post("/v1/session", tags=["identity"], operation_id="browser_session_create") + def create_browser_session( + request: Request, + response: Response, + authorization: str | None = Header(default=None), + ) -> dict[str, str]: + require_same_origin(request) + response.headers["Cache-Control"] = "no-store" + scheme, _, bearer_token = (authorization or "").partition(" ") + if scheme.lower() != "bearer" or not bearer_token: + raise HTTPException(status_code=401, detail="Bearer credential required") + principal = identity_provider.authenticate(bearer_token) + if principal is None: + raise HTTPException(status_code=401, detail="Credential is invalid or revoked") + if "agent" in principal.roles or not principal.roles.intersection( + {"admin", "editor", "viewer"} + ): + raise HTTPException( + status_code=403, detail="Browser sessions require a human identity" + ) + try: + session = identity_provider.create_browser_session(bearer_token) + except PermissionError as error: + raise HTTPException(status_code=403, detail=str(error)) from error + response.set_cookie( + key="gewu_session", + value=session.session_token, + httponly=True, + secure=request.url.scheme == "https", + samesite="strict", + path="/v1", + ) + return {"csrf_token": session.csrf_token, "expires_at": session.expires_at.isoformat()} + + @app.get("/v1/session", tags=["identity"], operation_id="browser_session_restore") + def restore_browser_session( + request: Request, + response: Response, + session_cookie: str | None = Cookie(default=None, alias="gewu_session"), + restore_header: str | None = Header(default=None, alias="X-GEWU-Session"), + ) -> dict[str, str | bool]: + require_same_origin(request) + if restore_header != "restore" or request.headers.get("sec-fetch-site") == "cross-site": + raise HTTPException(403, "Session restoration requires a same-origin client") + principal = authenticate(None, session_cookie) + session = identity_provider.restore_browser_session(session_cookie or "") + if session is None: + raise HTTPException( + 401, + "Browser session is invalid or expired", + headers={"Cache-Control": "no-store"}, + ) + response.headers["Cache-Control"] = "private, no-store" + return { + "csrf_token": session.csrf_token, + "expires_at": session.expires_at.isoformat(), + "password_account": principal.credential_id.startswith("account:"), + } + + @app.delete("/v1/session", tags=["identity"], operation_id="browser_session_revoke") + def revoke_browser_session( + request: Request, + response: Response, + session_cookie: str | None = Cookie(default=None, alias="gewu_session"), + csrf_token: str | None = Header(default=None, alias="X-GEWU-CSRF"), + ) -> dict[str, str]: + require_same_origin(request) + response.headers["Cache-Control"] = "no-store" + if session_cookie: + authenticate(None, session_cookie, csrf_token, require_csrf=True) + identity_provider.revoke_browser_session(session_cookie) + response.delete_cookie( + key="gewu_session", + path="/v1", + httponly=True, + secure=request.url.scheme == "https", + samesite="strict", + ) + return {"status": "signed_out"} + + @app.get( + "/v1/projects/{project_id}/nodes/{node_id}", + response_model=ReadEnvelope, + tags=["knowledge"], + operation_id="get_node", + ) + def get_node( + project_id: RecordId, + node_id: RecordId, + revision: GitCommitId | None = None, + authorization: str | None = Header(default=None), + session_cookie: str | None = Cookie(default=None, alias="gewu_session"), + ) -> ReadEnvelope: + principal = authenticate(authorization, session_cookie) + try: + envelope = node_reader.get_node( + project_id, node_id, principal=principal, revision=revision + ) + except ReadError as error: + raise HTTPException(status_code=error.status_code, detail=str(error)) from error + return envelope + + source_reader = CanonicalSourceReader(node_reader.repository) + http_operations = { + "diagnostics", + "browser_session_create", + "browser_session_revoke", + "browser_session_restore", + "get_node", + "node_history", + "get_source", + } + if credential_administration is not None: + http_operations.update( + { + "admin_list_credentials", + "admin_create_credential", + "admin_revoke_credential", + "admin_update_credential", + "admin_list_events", + } + ) + if account_identity is not None: + http_operations.update( + { + "account_login", + "account_current", + "account_change_password", + "account_list_agent_credentials", + "account_create_agent_credential", + "account_revoke_agent_credential", + "admin_list_accounts", + "admin_create_account", + "admin_update_account", + "admin_disable_account", + } + ) + if project_registry is not None: + http_operations.update( + { + "admin_list_projects", + "project_create", + "project_update_content", + "project_archive", + } + ) + + if project_registry is not None: + + @app.get( + "/v1/admin/projects", + response_model=list[ProjectRecord], + tags=["control"], + operation_id="admin_list_projects", + ) + def admin_list_projects( + response: Response, + authorization: str | None = Header(default=None), + session_cookie: str | None = Cookie(default=None, alias="gewu_session"), + ) -> list[ProjectRecord]: + response.headers["Cache-Control"] = "private, no-store" + principal = authenticate(authorization, session_cookie) + require_admin(principal) + if project_registry is None: + raise HTTPException(status_code=503, detail="Project registry is unavailable") + try: + records = list(project_registry.records()) + return records + except RegistryError as error: + raise HTTPException(status_code=503, detail=str(error)) from error + + @app.post( + "/v1/admin/projects", + response_model=ProjectRecord, + status_code=201, + tags=["control"], + operation_id="project_create", + ) + def project_create( + request: Request, + body: ProjectCreateBody, + response: Response, + authorization: str | None = Header(default=None), + session_cookie: str | None = Cookie(default=None, alias="gewu_session"), + csrf_token: str | None = Header(default=None, alias="X-GEWU-CSRF"), + ) -> ProjectRecord: + require_same_origin(request) + if session_cookie is not None and request.headers.get("origin") is None: + raise HTTPException( + status_code=403, detail="Browser session requires an Origin" + ) + principal = authenticate( + authorization, session_cookie, csrf_token, require_csrf=True + ) + require_admin(principal) + if project_registry is None: + raise HTTPException(status_code=503, detail="Project registry is unavailable") + try: + result = project_registry.create(body.project, body.policy) + except RegistryConflict as error: + raise HTTPException(status_code=409, detail=str(error)) from error + except portalocker.exceptions.LockException as error: + raise HTTPException( + status_code=409, detail="Project registry is busy" + ) from error + except ValueError as error: + raise HTTPException(status_code=422, detail=str(error)) from error + except RegistryError as error: + raise HTTPException(status_code=503, detail=str(error)) from error + response.headers["Cache-Control"] = "no-store" + return result + + @app.patch( + "/v1/admin/projects/{project_id}", + response_model=ProjectRecord, + tags=["control"], + operation_id="project_update_content", + ) + def project_update_content( + project_id: RecordId, + body: ProjectContentUpdateBody, + request: Request, + response: Response, + authorization: str | None = Header(default=None), + session_cookie: str | None = Cookie(default=None, alias="gewu_session"), + csrf_token: str | None = Header(default=None, alias="X-GEWU-CSRF"), + ) -> ProjectRecord: + require_same_origin(request) + if session_cookie is not None and request.headers.get("origin") is None: + raise HTTPException( + status_code=403, detail="Browser session requires an Origin" + ) + principal = authenticate( + authorization, session_cookie, csrf_token, require_csrf=True + ) + require_admin(principal) + if project_registry is None: + raise HTTPException(status_code=503, detail="Project registry is unavailable") + try: + existing = next( + ( + item + for item in project_registry.records() + if item.project_id == project_id + ), + None, + ) + if existing is None: + raise RegistryNotFound("Project is not registered") + result = project_registry.update_content( + project_id, + description=body.description, + north_star=body.north_star, + key_questions=tuple(body.key_questions), + ) + except RegistryNotFound as error: + raise HTTPException(status_code=404, detail=str(error)) from error + except portalocker.exceptions.LockException as error: + raise HTTPException( + status_code=409, detail="Project registry is busy" + ) from error + except ValueError as error: + raise HTTPException(status_code=422, detail=str(error)) from error + except RegistryError as error: + raise HTTPException(status_code=503, detail=str(error)) from error + response.headers["Cache-Control"] = "no-store" + return result + + @app.post( + "/v1/admin/projects/{project_id}/archive", + response_model=ProjectRecord, + tags=["control"], + operation_id="project_archive", + ) + def project_archive( + project_id: RecordId, + request: Request, + response: Response, + authorization: str | None = Header(default=None), + session_cookie: str | None = Cookie(default=None, alias="gewu_session"), + csrf_token: str | None = Header(default=None, alias="X-GEWU-CSRF"), + ) -> ProjectRecord: + require_same_origin(request) + if session_cookie is not None and request.headers.get("origin") is None: + raise HTTPException( + status_code=403, detail="Browser session requires an Origin" + ) + principal = authenticate( + authorization, session_cookie, csrf_token, require_csrf=True + ) + require_admin(principal) + if project_registry is None: + raise HTTPException(status_code=503, detail="Project registry is unavailable") + try: + result = project_registry.archive(project_id) + except RegistryNotFound as error: + raise HTTPException(status_code=404, detail=str(error)) from error + except portalocker.exceptions.LockException as error: + raise HTTPException( + status_code=409, detail="Project registry is busy" + ) from error + except ValueError as error: + raise HTTPException(status_code=422, detail=str(error)) from error + except RegistryError as error: + raise HTTPException(status_code=503, detail=str(error)) from error + response.headers["Cache-Control"] = "no-store" + return result + + @app.get( + "/v1/diagnostics", + response_model=ServiceDiagnostics, + tags=["diagnostics"], + operation_id="diagnostics", + ) + def diagnostics( + response: Response, + projects: Annotated[list[str] | None, Query()] = None, + include_shared: bool = False, + authorization: str | None = Header(default=None), + session_cookie: str | None = Cookie(default=None, alias="gewu_session"), + ) -> ServiceDiagnostics: + response.headers["Cache-Control"] = "private, no-store" + response.headers["X-Content-Type-Options"] = "nosniff" + principal = authenticate(authorization, session_cookie) + authorized_projects = ( + search_service.list_projects(principal=principal) + if search_service is not None + else tuple( + sorted( + project + for project in principal.project_ids + if principal.can_read_project(project) + ) + ) + ) + index_status = ( + search_service.diagnostic_index_status() + if search_service is not None + else (node_reader.repository.head(), None, "not_configured", None) + ) + try: + return build_service_diagnostics( + principal=principal, + transport="http", + authorized_projects=tuple(authorized_projects), + available_operations=frozenset(http_operations), + index_status=index_status, + projects=tuple(projects) if projects is not None else None, + include_shared=include_shared, + ) + except PermissionError as error: + raise HTTPException(status_code=403, detail=str(error)) from error + except ValueError as error: + raise HTTPException(status_code=422, detail=str(error)) from error + + @app.get( + "/v1/projects/{project_id}/nodes/{node_id}/history", + response_model=ReadEnvelope, + tags=["knowledge"], + operation_id="node_history", + ) + def get_node_history( + project_id: RecordId, + node_id: RecordId, + limit: int = 20, + authorization: str | None = Header(default=None), + session_cookie: str | None = Cookie(default=None, alias="gewu_session"), + ) -> ReadEnvelope: + principal = authenticate(authorization, session_cookie) + try: + return node_reader.get_history( + project_id, node_id, principal=principal, limit=limit + ) + except ReadError as error: + raise HTTPException(status_code=error.status_code, detail=str(error)) from error + + @app.get( + "/v1/projects/{project_id}/sources/{revision_id}", + response_model=ReadEnvelope, + tags=["knowledge"], + operation_id="get_source", + ) + def get_source( + project_id: RecordId, + revision_id: RecordId, + authorization: str | None = Header(default=None), + session_cookie: str | None = Cookie(default=None, alias="gewu_session"), + ) -> ReadEnvelope: + principal = authenticate(authorization, session_cookie) + try: + return source_reader.get_source(project_id, revision_id, principal=principal) + except ReadError as error: + raise HTTPException(status_code=error.status_code, detail=str(error)) from error + + if artifact_store is not None: + http_operations.update({"get_source_artifact", "get_source_location"}) + + @app.get( + "/v1/projects/{project_id}/sources/{revision_id}/artifact", + tags=["knowledge"], + operation_id="get_source_artifact", + ) + def get_source_artifact( + project_id: RecordId, + revision_id: RecordId, + authorization: str | None = Header(default=None), + session_cookie: str | None = Cookie(default=None, alias="gewu_session"), + ) -> StreamingResponse: + principal = authenticate(authorization, session_cookie) + try: + source, _ = source_reader.get_source_record( + project_id, revision_id, principal=principal + ) + if source.archive_status != "archived" or source.artifact_sha256 is None: + raise HTTPException( + status_code=404, detail="This source revision has no archived artifact" + ) + media_type = artifact_store.artifact_media_type_for_project( + source.artifact_sha256, project_id=project_id + ) + if media_type != source.media_type: + raise HTTPException( + status_code=503, detail="Archived source metadata is inconsistent" + ) + stream = artifact_store.open_artifact( + source.artifact_sha256, project_id=project_id + ) + if os.fstat(stream.fileno()).st_size != source.byte_length: + stream.close() + raise HTTPException( + status_code=503, detail="Archived source length does not match metadata" + ) + except ReadError as error: + raise HTTPException(status_code=error.status_code, detail=str(error)) from error + except UploadError as error: + raise HTTPException( + status_code=404, detail="Archived source is unavailable" + ) from error + + def chunks() -> Iterator[bytes]: + try: + while block := stream.read(1024 * 1024): + yield block + finally: + stream.close() + + return StreamingResponse( + chunks(), + media_type=media_type, + headers={ + "Cache-Control": "private, no-store", + "Content-Length": str(source.byte_length), + "ETag": f'"{source.artifact_sha256}"', + "X-Content-Type-Options": "nosniff", + "Content-Security-Policy": "default-src 'none'; sandbox", + "Content-Disposition": ( + 'attachment; filename="source.pdf"' + if media_type == "application/pdf" + else 'attachment; filename="source.txt"' + ), + }, + ) + + @app.get( + "/v1/projects/{project_id}/sources/{revision_id}/extractions/{extraction_id}/blocks/{block_id}", + tags=["knowledge"], + operation_id="get_source_location", + ) + def get_source_location( + project_id: RecordId, + revision_id: RecordId, + extraction_id: str, + block_id: RecordId, + response: Response, + offset: int = Query(ge=0), + length: int = Query(ge=1, le=12_000), + authorization: str | None = Header(default=None), + session_cookie: str | None = Cookie(default=None, alias="gewu_session"), + ) -> dict[str, object]: + response.headers["Cache-Control"] = "private, no-store" + response.headers["X-Content-Type-Options"] = "nosniff" + principal = authenticate(authorization, session_cookie) + try: + source, _ = source_reader.get_source_record( + project_id, revision_id, principal=principal + ) + except ReadError as error: + raise HTTPException(status_code=error.status_code, detail=str(error)) from error + extraction = artifact_store.get_extraction(extraction_id, project_id=project_id) + if extraction is None or extraction.source_revision_id != source.revision_id: + raise HTTPException(status_code=404, detail="Source location is unavailable") + block = next( + (item for item in extraction.blocks if item.block_id == block_id), None + ) + if block is None or offset >= len(block.normalized_text): + raise HTTPException(status_code=404, detail="Source location is unavailable") + end = min(offset + length, len(block.normalized_text)) + spans = block.normalization_map[offset:end] + original_start = min(start for start, _ in spans) + original_end = max(stop for _, stop in spans) + return { + "source_revision_id": revision_id, + "extraction_id": extraction.extraction_id, + "parser_name": extraction.parser_name, + "parser_version": extraction.parser_version, + "block_id": block_id, + "offset": offset, + "normalized_text": block.normalized_text[offset:end], + "original_text": block.original_text[original_start:original_end], + "normalization_map": spans, + } + + if search_service is not None: + http_operations.update({"list_projects", "search", "neighbors", "list_sources"}) + + @app.get("/v1/projects", tags=["knowledge"], operation_id="list_projects") + def list_projects( + authorization: str | None = Header(default=None), + session_cookie: str | None = Cookie(default=None, alias="gewu_session"), + ) -> list[str]: + principal = authenticate(authorization, session_cookie) + return list(search_service.list_projects(principal=principal)) + + @app.post( + "/v1/search", + response_model=ReadEnvelope, + tags=["knowledge"], + operation_id="search", + ) + def search( + request: SearchRequestBody, + authorization: str | None = Header(default=None), + session_cookie: str | None = Cookie(default=None, alias="gewu_session"), + csrf_token: str | None = Header(default=None, alias="X-GEWU-CSRF"), + ) -> ReadEnvelope: + principal = authenticate( + authorization, session_cookie, csrf_token, require_csrf=True + ) + try: + return search_service.search(request.to_contract(), principal=principal) + except SearchError as error: + raise HTTPException(status_code=error.status_code, detail=str(error)) from error + + @app.post( + "/v1/neighbors", + response_model=ReadEnvelope, + tags=["knowledge"], + operation_id="neighbors", + ) + def neighbors( + request: NeighborRequestBody, + authorization: str | None = Header(default=None), + session_cookie: str | None = Cookie(default=None, alias="gewu_session"), + csrf_token: str | None = Header(default=None, alias="X-GEWU-CSRF"), + ) -> ReadEnvelope: + principal = authenticate( + authorization, session_cookie, csrf_token, require_csrf=True + ) + try: + return search_service.neighbors(request.to_contract(), principal=principal) + except SearchError as error: + raise HTTPException(status_code=error.status_code, detail=str(error)) from error + + @app.post( + "/v1/sources", + response_model=ReadEnvelope, + tags=["knowledge"], + operation_id="list_sources", + ) + def list_sources( + request: SourceListRequestBody, + authorization: str | None = Header(default=None), + session_cookie: str | None = Cookie(default=None, alias="gewu_session"), + csrf_token: str | None = Header(default=None, alias="X-GEWU-CSRF"), + ) -> ReadEnvelope: + principal = authenticate( + authorization, session_cookie, csrf_token, require_csrf=True + ) + try: + return search_service.list_sources(request.to_contract(), principal=principal) + except SearchError as error: + raise HTTPException(status_code=error.status_code, detail=str(error)) from error + + mcp_server = create_mcp_server( + identity_provider=identity_provider, + node_reader=node_reader, + source_reader=source_reader, + search_service=search_service, + artifact_store=artifact_store, + extractor=extractor, + acceptance_service=acceptance_service, + context_provider=context_provider, + url_fetcher=url_fetcher or PublicUrlFetcher(frozenset()), + ) + mcp_asgi = mcp_server.streamable_http_app( + streamable_http_path="/", + stateless_http=True, + json_response=True, + max_request_body_size=1_048_576, + transport_security=TransportSecuritySettings( + allowed_hosts=[ + "localhost", + "localhost:*", + "127.0.0.1", + "127.0.0.1:*", + "[::1]", + "[::1]:*", + *mcp_allowed_hosts, + ] + ), + ) + owner_mcp_server = None + owner_mcp_asgi = None + if ( + owner_identity_provider is not None + and owner_identity_provider.owner_authentication_enabled + and owner_service_audience + and callable(getattr(identity_provider, "get_owner_challenge", None)) + and callable(getattr(identity_provider, "decide_owner_challenge", None)) + ): + owner_mcp_server = create_owner_mcp_server( + identities=owner_identity_provider, + reviews=cast(OwnerReviewProvider, identity_provider), + audience=owner_service_audience, + ) + owner_mcp_asgi = owner_mcp_server.streamable_http_app( + streamable_http_path="/", + stateless_http=True, + json_response=True, + max_request_body_size=1_048_576, + transport_security=TransportSecuritySettings( + allowed_hosts=[ + "localhost", + "localhost:*", + "127.0.0.1", + "127.0.0.1:*", + "[::1]", + "[::1]:*", + *mcp_allowed_hosts, + ] + ), + ) + original_lifespan = app.router.lifespan_context + + @asynccontextmanager + async def lifespan(application: FastAPI) -> AsyncIterator[None]: + async with AsyncExitStack() as stack: + await stack.enter_async_context(mcp_server.session_manager.run()) + if owner_mcp_server is not None: + await stack.enter_async_context(owner_mcp_server.session_manager.run()) + async with original_lifespan(application): + yield + + app.router.lifespan_context = lifespan + app.router.routes.append(Mount("/mcp", app=mcp_asgi)) + if owner_mcp_asgi is not None: + app.router.routes.append(Mount("/owner-mcp", app=owner_mcp_asgi)) + + return app + + +def create_local_app() -> FastAPI: + """Compose the local persistent service from one private data root.""" + + data_root = Path(os.environ.get("GEWU_DATA_ROOT", "./.data/gewu")).expanduser().absolute() + reject_symlink_components(data_root) + data_root.mkdir(parents=True, exist_ok=True, mode=0o700) + if data_root.is_symlink() or not data_root.is_dir(): + raise RuntimeError("GEWU data root must be a real directory") + data_root.chmod(0o700) + repository = GitCanonicalRepository(data_root / "knowledge.git") + workflow_root = data_root / "workflow" + authority_lock = workflow_root / "locks" / "canonical-writer.lock" + identities = SQLiteIdentityStore( + data_root / "identity.sqlite3", + authority_lock_path=authority_lock, + owner_encryption_key=os.environ.get("GEWU_OWNER_ENCRYPTION_KEY", "").encode("ascii") + or None, + ) + artifacts = LocalArtifactStore(data_root) + allowed_fetch_hosts = { + host.strip() + for host in os.environ.get("GEWU_FETCH_ALLOWED_HOSTS", "").split(",") + if host.strip() + } + url_fetcher = PublicUrlFetcher(allowed_fetch_hosts) if allowed_fetch_hosts else None + registry_file = data_root / "control" / "registry.json" + context_provider = LocalContextProvider(repository, registry_file) + project_registry = LocalProjectRegistry( + registry_file, + repository=repository, + index_directory=data_root / "publishing-index", + authority_lock_path=authority_lock, + ) + search_path = data_root / "search.sqlite3" + search_index: SearchIndex + model_path = os.environ.get("GEWU_EMBEDDING_MODEL_PATH") + model_digest = os.environ.get("GEWU_EMBEDDING_MODEL_SHA256") + if model_path or model_digest: + if not model_path or not model_digest: + raise RuntimeError( + "GEWU_EMBEDDING_MODEL_PATH and GEWU_EMBEDDING_MODEL_SHA256 " + "must be configured together" + ) + embeddings = LocalSentenceTransformer( + Path(model_path), + model_id=os.environ.get( + "GEWU_EMBEDDING_MODEL_ID", + "intfloat/multilingual-e5-small@614241f622f53c4eeff9890bdc4f31cfecc418b3", + ), + expected_sha256=model_digest, + dimensions=int(os.environ.get("GEWU_EMBEDDING_DIMENSIONS", "384")), + ) + vectors = SQLiteVectorIndex(search_path) + lexical_index = SQLiteSearchIndex(search_path, repository) + hybrid_index = HybridSearchIndex( + lexical=lexical_index, + vectors=vectors, + embeddings=embeddings, + ) + lexical_index.configure_vector_generation( + extension_loader=vectors.load_extension, + builder=hybrid_index.build_vector_generation, + is_current=hybrid_index.vector_generation_is_current, + ) + search_index = hybrid_index + else: + search_index = SQLiteSearchIndex(search_path, repository) + search_service = CanonicalSearchService( + index=search_index, + project_ids_provider=project_registry, + canonical_head=repository.head, + default_includes_provider=project_registry.default_includes, + ) + acceptance = AcceptanceService( + repository=repository, + artifact_store=artifacts, + workflow_root=workflow_root, + context_provider=context_provider, + actor_is_current=lambda actor: identities.is_current( + actor.principal_id, + actor.credential_id, + roles=actor.roles, + project_ids=actor.project_ids, + ), + owner_challenge_issuer=identities.issue_owner_challenge, + owner_decision_lookup=identities.authorized_owner_challenges, + ) + return create_app( + identity_provider=identities, + credential_administration=identities, + account_identity=identities, + owner_identity_provider=identities, + owner_service_audience=os.environ.get("GEWU_OWNER_SERVICE_AUDIENCE"), + node_reader=CanonicalNodeReader(repository), + artifact_store=artifacts, + extractor=LocalSourceExtractor(), + acceptance_service=acceptance, + context_provider=context_provider, + search_service=search_service, + project_registry=project_registry, + url_fetcher=url_fetcher, + ) diff --git a/src/gewu/bootstrap/owner_cli.py b/src/gewu/bootstrap/owner_cli.py new file mode 100644 index 0000000..c8a471f --- /dev/null +++ b/src/gewu/bootstrap/owner_cli.py @@ -0,0 +1,61 @@ +"""Restricted interactive owner-account provisioning command.""" + +from __future__ import annotations + +import argparse +import getpass +import os +import sys +from pathlib import Path + +import pyotp + +from gewu.adapters.sqlite_identity import SQLiteIdentityStore + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("principal_id") + parser.add_argument( + "--project", action="append", dest="project_ids", required=True, metavar="PROJECT_ID" + ) + arguments = parser.parse_args() + if not sys.stdin.isatty() or not sys.stdout.isatty(): + parser.error("owner provisioning requires an interactive terminal") + data_root = Path(os.environ.get("GEWU_DATA_ROOT", "./.data/gewu")).expanduser().absolute() + encryption_key = os.environ.get("GEWU_OWNER_ENCRYPTION_KEY") + if encryption_key is None: + parser.error("GEWU_OWNER_ENCRYPTION_KEY must be configured outside the database") + password = getpass.getpass("Owner password: ") + confirmation = getpass.getpass("Confirm owner password: ") + if password != confirmation: + parser.error("owner passwords did not match") + secret = pyotp.random_base32() + store = SQLiteIdentityStore( + data_root / "identity.sqlite3", + authority_lock_path=data_root / "workflow/locks/canonical-writer.lock", + owner_encryption_key=encryption_key.encode("ascii"), + ) + try: + store.provision_owner( + principal_id=arguments.principal_id, + password=password, + totp_secret=secret, + project_ids=tuple(arguments.project_ids), + ) + except (ValueError, RuntimeError) as error: + parser.error(str(error)) + finally: + password = "" + confirmation = "" + enrollment_uri = pyotp.TOTP(secret).provisioning_uri( + name=arguments.principal_id, issuer_name="GEWU" + ) + print("Add this one-time enrollment URI to the owner authenticator:") + print(enrollment_uri) + print("The URI contains the TOTP seed. Store it securely; it will not be shown again.") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/gewu/domain/__init__.py b/src/gewu/domain/__init__.py new file mode 100644 index 0000000..b568772 --- /dev/null +++ b/src/gewu/domain/__init__.py @@ -0,0 +1 @@ +"""Transport-independent GEWU contracts.""" diff --git a/src/gewu/domain/contracts.py b/src/gewu/domain/contracts.py new file mode 100644 index 0000000..4887072 --- /dev/null +++ b/src/gewu/domain/contracts.py @@ -0,0 +1,894 @@ +"""Versioned public records shared by GEWU transports and adapters.""" + +from __future__ import annotations + +import re +import unicodedata +from datetime import date, datetime +from enum import StrEnum +from typing import Annotated, Literal + +from pydantic import ( + BaseModel, + ConfigDict, + Field, + FiniteFloat, + StringConstraints, + field_validator, + model_validator, +) + +SCHEMA_VERSION = 1 + +RecordId = Annotated[ + str, + StringConstraints( + min_length=1, + max_length=128, + pattern=r"^[a-zA-Z0-9][a-zA-Z0-9._:-]*$", + ), +] +SearchFacetValue = Annotated[ + str, + StringConstraints(min_length=1, max_length=100), +] +Sha256 = Annotated[str, StringConstraints(pattern=r"^[a-f0-9]{64}$")] +GitCommitId = Annotated[str, StringConstraints(pattern=r"^(?:[a-f0-9]{40}|[a-f0-9]{64})$")] + + +class StrictRecord(BaseModel): + """Base for public records: reject implicit coercion and unknown fields.""" + + model_config = ConfigDict(extra="forbid", strict=True, frozen=True) + + schema_version: Literal[1] = 1 + + +class ProjectStatus(StrEnum): + ACTIVE = "active" + PAUSED = "paused" + ARCHIVED = "archived" + + +class ProjectRecord(StrictRecord): + project_id: RecordId + title: str = Field(min_length=1, max_length=200) + status: ProjectStatus + description: str = Field(default="", max_length=4000) + owners: tuple[RecordId, ...] = () + north_star: str = Field(min_length=1, max_length=4000) + key_questions: tuple[str, ...] = Field(min_length=1, max_length=30) + watch_list: tuple[str, ...] = Field(default=(), max_length=100) + default_includes: tuple[RecordId, ...] = ("shared",) + taxonomy_extensions: dict[str, tuple[str, ...]] = Field(default_factory=dict, max_length=100) + autonomy_policy_id: RecordId + + @field_validator("project_id") + @classmethod + def project_id_is_slug(cls, value: str) -> str: + if not re.fullmatch(r"[a-z0-9]+(?:-[a-z0-9]+)*", value): + raise ValueError("Project IDs must be lowercase hyphen-separated slugs") + return value + + +class ProjectLens(StrictRecord): + project_id: RecordId + title: str = Field(min_length=1, max_length=200) + status: ProjectStatus + north_star: str = Field(min_length=1, max_length=4000) + key_questions: tuple[str, ...] = Field(min_length=1, max_length=30) + watch_list: tuple[str, ...] = Field(default=(), max_length=100) + taxonomy_extensions: dict[str, tuple[str, ...]] = Field(default_factory=dict, max_length=100) + + +class ProjectContextResponse(StrictRecord): + lens: ProjectLens + + +class ReliabilityGrade(StrEnum): + LOW = "low" + MEDIUM = "medium" + HIGH = "high" + + +class AutoAcceptPolicy(StrictRecord): + policy_id: RecordId + policy_version: int = Field(ge=1) + project_id: RecordId + automatic_node_types: tuple[ + Literal[ + "finding", + "problem", + "technique", + "case", + "entity", + "event", + "concept", + "datapoint", + "synthesis", + ], + ..., + ] = () + automatic_edge_types: tuple[ + Literal[ + "solves", + "addresses", + "causes", + "supports", + "contradicts", + "instance_of", + "part_of", + "precedes", + "supersedes", + "compared_with", + "derived_from", + "applies_to", + ], + ..., + ] = () + automatic_source_registration: bool = False + require_supported_assessment: bool = True + require_owner_decision_for: tuple[ + Literal["delete", "merge", "promote", "intent", "policy"], ... + ] = ( + "delete", + "merge", + "promote", + "intent", + "policy", + ) + + +class SourceRevisionRecord(StrictRecord): + source_id: RecordId + revision_id: RecordId + artifact_sha256: Sha256 | None = None + media_type: str = Field(min_length=1, max_length=128) + byte_length: int = Field(ge=0) + captured_at: datetime + capture_method: Literal[ + "unspecified", "local-file", "url-fetch", "pasted-text", "notebooklm-export", "transcript" + ] = "unspecified" + original_url: str | None = Field(default=None, max_length=4000) + final_url: str | None = Field(default=None, max_length=4000) + title: str = Field(min_length=1, max_length=500) + authors: tuple[str, ...] = Field(default=(), max_length=50) + publisher: str | None = Field(default=None, max_length=300) + publication_date: datetime | None = None + license: str = Field(min_length=1, max_length=500) + source_type: Literal[ + "paper", "review", "press", "vendor", "internal", "talk", "export", "other" + ] + reliability_grade: ReliabilityGrade + ingested_for: tuple[RecordId, ...] = Field(min_length=1, max_length=50) + archive_status: Literal["archived", "reference-only", "capture-failed"] + + @field_validator("captured_at", "publication_date") + @classmethod + def timestamps_are_utc(cls, value: datetime | None) -> datetime | None: + offset = value.utcoffset() if value is not None and value.tzinfo is not None else None + if value is not None and (offset is None or offset.total_seconds() != 0): + raise ValueError("timestamps must be timezone-aware UTC") + return value + + @model_validator(mode="after") + def archive_metadata_matches_artifact(self) -> SourceRevisionRecord: + if self.archive_status == "archived": + if self.artifact_sha256 is None or self.byte_length < 1: + raise ValueError("archived sources require a non-empty archived artifact") + elif self.artifact_sha256 is not None or self.byte_length != 0: + raise ValueError("unarchived sources cannot claim artifact bytes or a digest") + if self.archive_status != "archived" and not self.original_url: + raise ValueError("unarchived source records require an original URL") + return self + + +class ExtractionBlock(StrictRecord): + block_id: RecordId + ordinal: int = Field(ge=0) + original_text: str = Field(max_length=100_000) + normalized_text: str = Field(max_length=100_000) + normalization_map: tuple[tuple[int, int], ...] = Field(max_length=100_000) + page: int | None = Field(default=None, ge=1) + time_start_ms: int | None = Field(default=None, ge=0) + time_end_ms: int | None = Field(default=None, ge=0) + + @model_validator(mode="after") + def validate_normalization_map(self) -> ExtractionBlock: + if len(self.normalization_map) != len(self.normalized_text): + raise ValueError("normalization map must cover every normalized code point") + if (self.time_start_ms is None) != (self.time_end_ms is None): + raise ValueError("transcript cue start and end times must be provided together") + if self.time_start_ms is not None and self.time_end_ms is not None: + if self.time_end_ms <= self.time_start_ms: + raise ValueError("transcript cue end must follow its start") + previous_start = -1 + for start, end in self.normalization_map: + if start < previous_start or end <= start or end > len(self.original_text): + raise ValueError("normalization map contains an invalid original span") + previous_start = start + return self + + +class ExtractionRecord(StrictRecord): + extraction_id: Sha256 + source_revision_id: RecordId + parser_name: str = Field(min_length=1, max_length=100) + parser_version: str = Field(min_length=1, max_length=100) + parser_config_sha256: Sha256 + normalization_profile: Literal["unicode-nfc-lf-v1"] + status: Literal["complete", "partial", "unavailable"] + blocks: tuple[ExtractionBlock, ...] = Field(max_length=100_000) + warnings: tuple[str, ...] = () + + +class Claim(StrictRecord): + claim_id: RecordId + text: str = Field(min_length=1, max_length=4000) + evidence_type: Literal["measured", "reported", "opinion", "vendor_marketing"] + evidence: tuple[EvidenceReference, ...] = Field(min_length=1, max_length=20) + assessment: SupportAssessment + + @model_validator(mode="after") + def assessment_matches_evidence(self) -> Claim: + expected_digests = {item.assertion_digest for item in self.evidence} + if expected_digests != {self.assessment.assertion_digest}: + raise ValueError("support assessment must bind the claim's exact assertion digest") + return self + + +class NodeType(StrEnum): + FINDING = "finding" + PROBLEM = "problem" + TECHNIQUE = "technique" + CASE = "case" + ENTITY = "entity" + EVENT = "event" + CONCEPT = "concept" + DATAPOINT = "datapoint" + SYNTHESIS = "synthesis" + + +class ReviewState(StrEnum): + DRAFT = "draft" + REVIEWED = "reviewed" + + +class Lifecycle(StrEnum): + ACTIVE = "active" + SUPERSEDED = "superseded" + + +class Confidence(StrEnum): + LOW = "low" + MEDIUM = "medium" + HIGH = "high" + + +class LensFit(StrictRecord): + score: FiniteFloat = Field(ge=0, le=1) + horizon: Literal["H1", "H2", "H3"] + key_question: str | None = Field(default=None, max_length=1000) + reason: str = Field(min_length=1, max_length=1000) + + +class KnowledgeNode(StrictRecord): + node_id: RecordId + project_id: RecordId + node_type: NodeType + title: str = Field(min_length=1, max_length=300) + summary: str = Field(min_length=1, max_length=8000) + created: datetime + updated: datetime + review_state: ReviewState + lifecycle: Lifecycle + confidence: Confidence + claim_support: Literal["pending", "supported", "unsupported"] + assumptions: tuple[Claim, ...] = Field(default=(), max_length=100) + limitations: tuple[Claim, ...] = Field(default=(), max_length=100) + tags: tuple[str, ...] = Field(default=(), max_length=100) + audience: tuple[str, ...] = Field(default=(), max_length=20) + domain: tuple[str, ...] = Field(default=(), max_length=20) + use_for: tuple[str, ...] = Field(default=(), max_length=20) + lens_fit: dict[RecordId, LensFit] = Field(default_factory=dict, max_length=50) + run_id: RecordId + agent_id: RecordId + model_id: str = Field(min_length=1, max_length=200) + claims: tuple[Claim, ...] = Field(min_length=1, max_length=100) + as_of: datetime | None = None + stale_after: datetime | None = None + supersedes: tuple[RecordId, ...] = () + superseded_by: tuple[RecordId, ...] = () + aliases: tuple[str, ...] = Field(default=(), max_length=100) + merged_from: tuple[RecordId, ...] = Field(default=(), max_length=100) + value: FiniteFloat | None = None + unit: str | None = Field(default=None, max_length=100) + sample_size: int | None = Field(default=None, ge=0) + reporting_year: int | None = Field(default=None, ge=1, le=9999) + demographic: str | None = Field(default=None, max_length=1000) + + @property + def evidence_claims(self) -> tuple[Claim, ...]: + """All factual statements that carry citation and assessment requirements.""" + + return (*self.claims, *self.assumptions, *self.limitations) + + @field_validator("aliases") + @classmethod + def normalize_aliases(cls, values: tuple[str, ...]) -> tuple[str, ...]: + return tuple(" ".join(unicodedata.normalize("NFC", value).split()) for value in values) + + @model_validator(mode="after") + def datapoint_has_freshness(self) -> KnowledgeNode: + claim_ids = [claim.claim_id for claim in self.evidence_claims] + if len(claim_ids) != len(set(claim_ids)): + raise ValueError("claim IDs must be unique across claims, assumptions and limitations") + normalized_aliases = [" ".join(alias.split()).casefold() for alias in self.aliases] + if any(not alias for alias in normalized_aliases): + raise ValueError("aliases cannot be empty") + if len(normalized_aliases) != len(set(normalized_aliases)): + raise ValueError("aliases must be unique ignoring case and whitespace") + normalized_title = " ".join(self.title.split()).casefold() + if normalized_title in normalized_aliases: + raise ValueError("aliases cannot repeat the node title") + if self.node_type == NodeType.DATAPOINT and ( + self.as_of is None + or self.stale_after is None + or self.value is None + or self.unit is None + ): + raise ValueError("datapoints require as_of, stale_after, value and unit") + if self.as_of and self.stale_after and self.stale_after <= self.as_of: + raise ValueError("stale_after must be later than as_of") + if self.updated < self.created: + raise ValueError("updated must not precede created") + if self.claim_support != "supported": + raise ValueError("accepted factual nodes must carry supported claim status") + if self.lifecycle == Lifecycle.SUPERSEDED and not self.superseded_by: + raise ValueError("superseded nodes require a superseded_by target") + return self + + @field_validator("created", "updated", "as_of", "stale_after") + @classmethod + def node_timestamps_are_utc(cls, value: datetime | None) -> datetime | None: + offset = value.utcoffset() if value is not None and value.tzinfo is not None else None + if value is not None and (offset is None or offset.total_seconds() != 0): + raise ValueError("timestamps must be timezone-aware UTC") + return value + + +class EdgeEndpoint(StrictRecord): + kind: Literal["node", "source", "project"] + id: RecordId + + +class TypedEdge(StrictRecord): + edge_id: RecordId + edge_type: Literal[ + "solves", + "addresses", + "causes", + "supports", + "contradicts", + "instance_of", + "part_of", + "precedes", + "supersedes", + "compared_with", + "derived_from", + "applies_to", + ] + source: EdgeEndpoint + target: EdgeEndpoint + conditions: str | None = Field(default=None, max_length=2000) + evidence: tuple[EvidenceReference, ...] = () + interpretation_rationale: str | None = Field(default=None, max_length=4000) + + @model_validator(mode="after") + def substantive_edge_has_basis(self) -> TypedEdge: + if self.edge_type == "derived_from" and ( + self.source.kind != "node" or self.target.kind != "source" + ): + raise ValueError("derived_from connects a knowledge node to a source revision") + if self.edge_type == "applies_to" and ( + self.source.kind != "node" or self.target.kind != "project" + ): + raise ValueError("applies_to connects a knowledge node to a project") + if self.edge_type not in {"derived_from", "applies_to"} and ( + self.source.kind != "node" or self.target.kind != "node" + ): + raise ValueError("this edge type connects two knowledge nodes") + if self.edge_type in {"solves", "addresses", "causes", "supports", "contradicts"}: + if not self.evidence and not self.interpretation_rationale: + raise ValueError( + "substantive edges require evidence or labeled interpretive rationale" + ) + return self + + +class ProposalOperation(StrictRecord): + operation: Literal["register_source", "upsert_node", "add_edge", "flag_stale"] + source: SourceRevisionRecord | None = None + node: KnowledgeNode | None = None + edge: TypedEdge | None = None + target_id: RecordId | None = None + + @model_validator(mode="after") + def payload_matches_operation(self) -> ProposalOperation: + payloads = { + "register_source": self.source, + "upsert_node": self.node, + "add_edge": self.edge, + } + expected = payloads.get(self.operation) + supplied = sum(value is not None for value in (self.source, self.node, self.edge)) + if self.operation == "flag_stale": + if supplied or self.target_id is None: + raise ValueError( + "flag_stale uses an explicit target elsewhere, not a record payload" + ) + elif supplied != 1 or expected is None: + raise ValueError("operation must carry exactly its matching typed payload") + elif self.target_id is not None: + raise ValueError("target_id is only valid for flag_stale") + return self + + +class ProposalRevision(StrictRecord): + proposal_id: RecordId + revision: int = Field(ge=1) + initiating_project_id: RecordId + scope: Scope + base_commit: GitCommitId + payload_digest: Sha256 + proposer_id: RecordId + run_id: RecordId + operations: tuple[ProposalOperation, ...] = Field(min_length=1, max_length=100) + + +class Scope(StrictRecord): + """Project context plus optional project includes; all-project access is deliberate.""" + + projects: tuple[RecordId, ...] = Field(min_length=1, max_length=50) + include_projects: tuple[RecordId, ...] | None = Field(default=None, max_length=50) + include_shared: bool = True + all_projects: bool = False + + @model_validator(mode="after") + def validate_all_projects(self) -> Scope: + if self.all_projects and self.projects != ("all",): + raise ValueError("all_projects requires projects=['all']") + if not self.all_projects and "all" in self.projects: + raise ValueError("projects=['all'] requires all_projects=true") + if len(set(self.projects)) != len(self.projects): + raise ValueError("scope projects must be unique") + if self.include_projects is not None: + if len(set(self.include_projects)) != len(self.include_projects): + raise ValueError("scope includes must be unique") + if set(self.projects) & set(self.include_projects): + raise ValueError("scope includes cannot repeat project context") + if "shared" in self.include_projects: + raise ValueError("shared scope must use include_shared") + if self.all_projects and self.include_projects is not None: + raise ValueError("all_projects cannot be combined with project includes") + return self + + +class SearchRequest(StrictRecord): + query: str = Field(default="", max_length=1000) + scope: Scope + node_types: tuple[NodeType, ...] = Field(default=(), max_length=9) + tags: tuple[SearchFacetValue, ...] = Field(default=(), max_length=20) + audience: tuple[SearchFacetValue, ...] = Field(default=(), max_length=20) + use_for: tuple[SearchFacetValue, ...] = Field(default=(), max_length=20) + domain: tuple[SearchFacetValue, ...] = Field(default=(), max_length=20) + confidences: tuple[Confidence, ...] = Field(default=(), max_length=3) + as_of_from: date | None = None + as_of_to: date | None = None + stale_only: bool = False + limit: int = Field(default=20, ge=1, le=50) + cursor: str | None = Field(default=None, max_length=512) + budget_tokens: int = Field(ge=256, le=50_000) + + @field_validator("query") + @classmethod + def normalize_query(cls, value: str) -> str: + return value.strip() + + @model_validator(mode="after") + def require_query_or_type_filter(self) -> SearchRequest: + if not self.query and not self.node_types: + raise ValueError("search requires text or at least one knowledge type") + if self.as_of_from is not None and self.as_of_to is not None: + if self.as_of_from > self.as_of_to: + raise ValueError("as_of_from must not be later than as_of_to") + return self + + @field_validator("node_types") + @classmethod + def search_node_types_are_unique(cls, value: tuple[NodeType, ...]) -> tuple[NodeType, ...]: + if len(value) != len(set(value)): + raise ValueError("search node types must be unique") + return value + + @field_validator("confidences") + @classmethod + def search_confidences_are_unique(cls, value: tuple[Confidence, ...]) -> tuple[Confidence, ...]: + if len(value) != len(set(value)): + raise ValueError("search confidences must be unique") + return value + + @field_validator("tags", "audience", "use_for", "domain") + @classmethod + def search_facet_values_are_unique_and_trimmed(cls, value: tuple[str, ...]) -> tuple[str, ...]: + if any(item != item.strip() for item in value): + raise ValueError("search facet values cannot have leading or trailing whitespace") + if len(value) != len(set(value)): + raise ValueError("search facet values must be unique") + return value + + +class NeighborRequest(StrictRecord): + node_id: RecordId + scope: Scope + edge_types: tuple[ + Literal[ + "solves", + "addresses", + "causes", + "supports", + "contradicts", + "instance_of", + "part_of", + "precedes", + "supersedes", + "compared_with", + "derived_from", + "applies_to", + ], + ..., + ] = Field(default=(), max_length=12) + limit: int = Field(default=20, ge=1, le=50) + cursor: str | None = Field(default=None, max_length=512) + budget_tokens: int = Field(ge=256, le=50_000) + + +class SourceListRequest(StrictRecord): + scope: Scope + limit: int = Field(default=20, ge=1, le=50) + cursor: str | None = Field(default=None, max_length=512) + budget_tokens: int = Field(ge=256, le=50_000) + + +class Locator(StrictRecord): + kind: Literal["text_span", "pdf_page_span", "transcript_timestamp_span"] + block_id: RecordId + start: int = Field(ge=0) + end: int = Field(gt=0) + page: int | None = Field(default=None, ge=1) + time_start_ms: int | None = Field(default=None, ge=0) + time_end_ms: int | None = Field(default=None, ge=0) + + @model_validator(mode="after") + def validate_span(self) -> Locator: + if self.end <= self.start: + raise ValueError("locator end must be greater than start") + if self.kind == "pdf_page_span" and self.page is None: + raise ValueError("PDF locators require a one-based page") + if self.kind != "pdf_page_span" and self.page is not None: + raise ValueError("only PDF locators can specify a page") + if self.kind == "transcript_timestamp_span": + if self.time_start_ms is None or self.time_end_ms is None: + raise ValueError("transcript locators require cue start and end times") + if self.time_end_ms <= self.time_start_ms: + raise ValueError("transcript locator end must follow its start") + elif self.time_start_ms is not None or self.time_end_ms is not None: + raise ValueError("only transcript locators can specify cue times") + return self + + +class EvidenceReference(StrictRecord): + source_revision_id: RecordId + extraction_id: Sha256 + assertion_path: str = Field(min_length=1, max_length=256) + assertion_digest: Sha256 + quote: str = Field(min_length=1, max_length=8000) + locator: Locator + normalization_profile: Literal["unicode-nfc-lf-v1"] + + +class SupportStatus(StrEnum): + SUPPORTED = "supported" + UNSUPPORTED = "unsupported" + UNAVAILABLE = "unavailable" + + +class SupportAssessment(StrictRecord): + assessment_id: RecordId + assertion_digest: Sha256 + evidence_digests: tuple[Sha256, ...] = Field(min_length=1, max_length=20) + assessor_id: RecordId + run_id: RecordId + method: str = Field(min_length=1, max_length=100) + status: SupportStatus + rationale: str = Field(min_length=1, max_length=4000) + + +class ProposalState(StrEnum): + STAGED = "staged" + VALIDATING = "validating" + NEEDS_REVIEW = "needs-review" + ACCEPTED = "accepted" + REJECTED = "rejected" + WITHDRAWN = "withdrawn" + + +class Finding(StrictRecord): + code: RecordId + message: str = Field(min_length=1, max_length=1000) + path: str | None = Field(default=None, max_length=256) + + +class ProposalReceipt(StrictRecord): + proposal_id: RecordId + revision: int = Field(ge=1) + payload_digest: Sha256 + state: ProposalState + outcome: Literal["accepted", "held", "rejected", "validation-failed"] + commit_id: GitCommitId | None = None + accepted_node_ids: tuple[RecordId, ...] = () + findings: tuple[Finding, ...] = () + decided_at: datetime | None = None + + +class ProposalStatus(StrictRecord): + proposal_id: RecordId + revision: int = Field(ge=1) + lifecycle: Literal[ + "staged", + "evaluated", + "publishing", + "published-unreconciled", + "accepted", + "withdrawn", + ] + validation_status: Literal["eligible", "needs-review", "invalid", "conflict"] | None = None + eligible_for_auto_accept: bool | None = None + findings: tuple[Finding, ...] = Field(default=(), max_length=200) + receipt: ProposalReceipt | None = None + canonical_revision: GitCommitId | None = None + + +class RunModulePin(StrictRecord): + module_id: RecordId + implementation_version: str = Field(min_length=1, max_length=64) + sha256: Sha256 + + +class RunCoverageItem(StrictRecord): + item: str = Field(min_length=1, max_length=500) + reason: str = Field(min_length=1, max_length=1000) + + +class RunCoverage(StrictRecord): + selected: tuple[str, ...] = Field(default=(), max_length=10_000) + included: tuple[str, ...] = Field(default=(), max_length=10_000) + excluded: tuple[RunCoverageItem, ...] = Field(default=(), max_length=10_000) + skipped: tuple[RunCoverageItem, ...] = Field(default=(), max_length=10_000) + + +class RunSourceReference(StrictRecord): + source_revision_id: RecordId + artifact_sha256: Sha256 | None = None + extraction_id: Sha256 | None = None + status: Literal["selected", "captured", "extracted", "unavailable", "failed"] + + +class RunOperationReference(StrictRecord): + operation_id: RecordId + tool_name: RecordId + input_sha256: Sha256 + status: Literal["pending", "uncertain", "complete", "failed"] + + +class RunProposalReference(StrictRecord): + proposal_id: RecordId + revision: int = Field(ge=1) + status: Literal[ + "staged", + "evaluated", + "eligible", + "needs-review", + "invalid", + "conflict", + "publishing", + "accepted", + "rejected", + "withdrawn", + "uncertain", + ] + receipt: ProposalReceipt | None = None + + @model_validator(mode="after") + def receipt_matches_proposal(self) -> RunProposalReference: + if self.status == "accepted": + if ( + self.receipt is None + or self.receipt.proposal_id != self.proposal_id + or self.receipt.revision != self.revision + or self.receipt.state != ProposalState.ACCEPTED + ): + raise ValueError("accepted run proposal requires its matching service receipt") + return self + + +class RunState(StrEnum): + ACTIVE = "active" + COMPLETED = "completed" + BLOCKED = "blocked" + + +class RunCreateRequest(StrictRecord): + run_id: RecordId + objective: str = Field(min_length=1, max_length=4000) + scope: Scope + lens: str = Field(min_length=1, max_length=2000) + pack_id: RecordId + pack_version: str = Field(min_length=1, max_length=64) + pack_snapshot_digest: Sha256 + module_pins: tuple[RunModulePin, ...] = Field(min_length=1, max_length=100) + + @model_validator(mode="after") + def explicit_scope_and_unique_modules(self) -> RunCreateRequest: + if self.scope.all_projects: + raise ValueError("runs require explicit project scope") + module_ids = tuple(module.module_id for module in self.module_pins) + if len(set(module_ids)) != len(module_ids): + raise ValueError("run module pins must have unique IDs") + if not self.objective.strip() or not self.lens.strip(): + raise ValueError("run objective and lens must not be blank") + return self + + +class RunCheckpointRequest(StrictRecord): + run_id: RecordId + expected_revision: int = Field(ge=0, le=2**31 - 2) + state: RunState = RunState.ACTIVE + stage: RecordId + sources: tuple[RunSourceReference, ...] = Field(default=(), max_length=500) + coverage: RunCoverage = Field(default_factory=RunCoverage) + budgets: dict[RecordId, Annotated[int, Field(ge=0)]] = Field( + default_factory=dict, max_length=100 + ) + pending_operations: tuple[RunOperationReference, ...] = Field(default=(), max_length=1000) + proposals: tuple[RunProposalReference, ...] = Field(default=(), max_length=1000) + output_hashes: tuple[Sha256, ...] = Field(default=(), max_length=1000) + + +class RunRecord(StrictRecord): + run_id: RecordId + revision: int = Field(ge=0) + state: RunState + objective: str = Field(min_length=1, max_length=4000) + scope: Scope + lens: str = Field(min_length=1, max_length=2000) + pack_id: RecordId + pack_version: str = Field(min_length=1, max_length=64) + pack_snapshot_digest: Sha256 + module_pins: tuple[RunModulePin, ...] = Field(min_length=1, max_length=100) + stage: RecordId + sources: tuple[RunSourceReference, ...] = Field(default=(), max_length=500) + coverage: RunCoverage = Field(default_factory=RunCoverage) + budgets: dict[RecordId, Annotated[int, Field(ge=0)]] = Field( + default_factory=dict, max_length=100 + ) + pending_operations: tuple[RunOperationReference, ...] = Field(default=(), max_length=1000) + proposals: tuple[RunProposalReference, ...] = Field(default=(), max_length=1000) + output_hashes: tuple[Sha256, ...] = Field(default=(), max_length=1000) + created_at: datetime + updated_at: datetime + checkpoint_digest: Sha256 | None = None + + +class ChangedFile(StrictRecord): + path: str = Field(min_length=1, max_length=500) + content_sha256: Sha256 + + +class PolicyBinding(StrictRecord): + project_id: RecordId + policy_id: RecordId + policy_version: int = Field(ge=1) + + +class OwnerDecisionReference(StrictRecord): + challenge_id: RecordId + owner_principal_id: RecordId + owner_session_id: Sha256 + decision_digest: Sha256 + domains: tuple[RecordId, ...] = Field(min_length=1, max_length=100) + grant_version: int = Field(ge=1) + decision: Literal["authorize"] = "authorize" + rationale: str = Field(min_length=1, max_length=2000) + decided_at: datetime + + +class AcceptanceManifest(StrictRecord): + proposal_id: RecordId + proposal_revision: int = Field(ge=1) + payload_digest: Sha256 + before_revision: GitCommitId + changed_files: tuple[ChangedFile, ...] = Field(min_length=1, max_length=200) + accepted_node_ids: tuple[RecordId, ...] = () + actor_id: RecordId + credential_id: RecordId + policy_bindings: tuple[PolicyBinding, ...] + owner_decisions: tuple[OwnerDecisionReference, ...] = Field(default=(), max_length=100) + decided_at: datetime + + +class ErrorCode(StrEnum): + SCOPE_REQUIRED = "SCOPE_REQUIRED" + OUT_OF_SCOPE = "OUT_OF_SCOPE" + UNAUTHORIZED = "UNAUTHORIZED" + OWNER_DECISION_REQUIRED = "OWNER_DECISION_REQUIRED" + POLICY_MISSING = "POLICY_MISSING" + EVIDENCE_MISMATCH = "EVIDENCE_MISMATCH" + SUPPORT_UNAVAILABLE = "SUPPORT_UNAVAILABLE" + BASE_CONFLICT = "BASE_CONFLICT" + IDEMPOTENCY_CONFLICT = "IDEMPOTENCY_CONFLICT" + BUDGET_TOO_SMALL = "BUDGET_TOO_SMALL" + UPLOAD_EXPIRED = "UPLOAD_EXPIRED" + BUSY = "BUSY" + RECOVERY_REQUIRED = "RECOVERY_REQUIRED" + + +class DomainError(StrictRecord): + code: ErrorCode + message: str = Field(min_length=1, max_length=1000) + findings: tuple[Finding, ...] = () + retryable: bool + request_id: RecordId + + +class BudgetProfile(StrictRecord): + tokenizer: str = Field(min_length=1, max_length=100) + encoding: str = Field(min_length=1, max_length=100) + + +class ReadEnvelope(StrictRecord): + api_version: Literal["v1"] = "v1" + request_id: RecordId + resolved_scope: Scope + data: tuple[dict[str, object], ...] + citations: tuple[EvidenceReference, ...] = () + warnings: tuple[Finding, ...] = () + omissions: tuple[Finding, ...] = () + canonical_revision: GitCommitId | None + index_revision: GitCommitId | None + retrieval_mode: Literal["keyword", "hybrid", "canonical"] + budget_profile: BudgetProfile + tokens_used: int = Field(ge=0) + next_cursor: str | None = None + + +class ServiceDiagnostics(StrictRecord): + service_version: str = Field(min_length=1, max_length=64) + api_version: Literal["v1"] = "v1" + catalog_sha256: Sha256 + transport: Literal["http", "mcp"] + authentication: Literal["bearer", "browser_session"] + roles: tuple[RecordId, ...] = () + authorized_projects: tuple[RecordId, ...] = () + selected_scope: Scope | None = None + capabilities: tuple[RecordId, ...] = () + authorized_operations: tuple[RecordId, ...] = () + connection_status: Literal["authenticated"] = "authenticated" + workflow_status: Literal["not_verified"] = "not_verified" + max_upload_bytes: int = Field(ge=1) + upload_chunk_bytes: int = Field(ge=1) + supported_media_types: tuple[str, ...] = Field(default=(), max_length=20) + canonical_revision: GitCommitId + index_state: Literal["current", "lagging", "not_built", "not_configured", "unavailable"] + index_revision: GitCommitId | None = None + index_schema_version: int | None = Field(default=None, ge=1) diff --git a/src/gewu/domain/operations.py b/src/gewu/domain/operations.py new file mode 100644 index 0000000..c96cd49 --- /dev/null +++ b/src/gewu/domain/operations.py @@ -0,0 +1,112 @@ +"""Allowlisted operation catalog shared by transport adapters.""" + +import hashlib +import json +from dataclasses import dataclass +from enum import StrEnum +from typing import Final + + +class Transport(StrEnum): + HTTP = "http" + MCP = "mcp" + OWNER_MCP = "owner_mcp" + STDIO = "stdio" + + +@dataclass(frozen=True, slots=True) +class Operation: + name: str + capability: str + transports: frozenset[Transport] + mutates_knowledge: bool = False + + +OPERATIONS: Final[tuple[Operation, ...]] = ( + Operation("health", "health:read", frozenset({Transport.HTTP})), + Operation("diagnostics", "service:diagnostics", frozenset({Transport.HTTP, Transport.MCP})), + Operation("browser_session_create", "identity:session", frozenset({Transport.HTTP})), + Operation("browser_session_restore", "identity:session", frozenset({Transport.HTTP})), + Operation("browser_session_revoke", "identity:session", frozenset({Transport.HTTP})), + Operation("account_login", "identity:session", frozenset({Transport.HTTP})), + Operation("account_current", "identity:session", frozenset({Transport.HTTP})), + Operation("account_change_password", "identity:session", frozenset({Transport.HTTP})), + Operation("account_list_agent_credentials", "identity:session", frozenset({Transport.HTTP})), + Operation("account_create_agent_credential", "identity:session", frozenset({Transport.HTTP})), + Operation("account_revoke_agent_credential", "identity:session", frozenset({Transport.HTTP})), + Operation("admin_list_accounts", "credential:admin", frozenset({Transport.HTTP})), + Operation("admin_create_account", "credential:admin", frozenset({Transport.HTTP})), + Operation("admin_update_account", "credential:admin", frozenset({Transport.HTTP})), + Operation("admin_disable_account", "credential:admin", frozenset({Transport.HTTP})), + Operation("owner_session_create", "owner:authenticate", frozenset({Transport.HTTP})), + Operation("admin_list_credentials", "credential:admin", frozenset({Transport.HTTP})), + Operation("admin_create_credential", "credential:admin", frozenset({Transport.HTTP})), + Operation("admin_update_credential", "credential:admin", frozenset({Transport.HTTP})), + Operation("admin_revoke_credential", "credential:admin", frozenset({Transport.HTTP})), + Operation("admin_list_events", "credential:admin", frozenset({Transport.HTTP})), + Operation("admin_list_projects", "project:admin", frozenset({Transport.HTTP})), + Operation("project_create", "project:admin", frozenset({Transport.HTTP})), + Operation("project_update_content", "project:admin", frozenset({Transport.HTTP})), + Operation("project_archive", "project:admin", frozenset({Transport.HTTP})), + Operation("list_projects", "knowledge:read", frozenset({Transport.HTTP, Transport.MCP})), + Operation("get_project_context", "knowledge:read", frozenset({Transport.MCP})), + Operation("search", "knowledge:read", frozenset({Transport.HTTP, Transport.MCP})), + Operation("list_sources", "knowledge:read", frozenset({Transport.HTTP, Transport.MCP})), + Operation("get_node", "knowledge:read", frozenset({Transport.HTTP, Transport.MCP})), + Operation("node_history", "knowledge:read", frozenset({Transport.HTTP, Transport.MCP})), + Operation("get_source", "knowledge:read", frozenset({Transport.HTTP, Transport.MCP})), + Operation("get_source_artifact", "knowledge:read", frozenset({Transport.HTTP})), + Operation("get_source_location", "knowledge:read", frozenset({Transport.HTTP})), + Operation("neighbors", "knowledge:read", frozenset({Transport.HTTP, Transport.MCP})), + Operation("source_capture_url", "proposal:submit", frozenset({Transport.MCP})), + Operation("source_capture_text", "proposal:submit", frozenset({Transport.MCP})), + Operation("source_upload_begin", "proposal:submit", frozenset({Transport.MCP})), + Operation("source_upload_status", "proposal:submit", frozenset({Transport.MCP})), + Operation("source_upload_chunk", "proposal:submit", frozenset({Transport.MCP})), + Operation("source_upload_finalize", "proposal:submit", frozenset({Transport.MCP})), + Operation("source_extract", "proposal:submit", frozenset({Transport.MCP})), + Operation("source_extraction_block", "proposal:submit", frozenset({Transport.MCP})), + Operation("proposal_submit", "proposal:submit", frozenset({Transport.MCP}), True), + Operation("proposal_evaluate", "proposal:evaluate", frozenset({Transport.MCP})), + Operation("proposal_status", "proposal:read", frozenset({Transport.MCP})), + Operation("proposal_withdraw", "proposal:withdraw", frozenset({Transport.MCP})), + Operation("run_create", "run:write", frozenset({Transport.MCP})), + Operation("run_checkpoint", "run:write", frozenset({Transport.MCP})), + Operation("run_status", "run:read", frozenset({Transport.MCP})), + Operation("owner_challenge_get", "owner:review", frozenset({Transport.OWNER_MCP})), + Operation("owner_diff_get", "owner:review", frozenset({Transport.OWNER_MCP})), + Operation("owner_decide", "owner:decide", frozenset({Transport.OWNER_MCP})), + Operation("list_selected_files", "local:file:read", frozenset({Transport.STDIO})), + Operation("upload_selected_file", "proposal:submit", frozenset({Transport.STDIO})), +) + + +def operations_for(transport: Transport) -> tuple[Operation, ...]: + """Return the explicit allowlist for a transport.""" + + return tuple(operation for operation in OPERATIONS if transport in operation.transports) + + +def catalog_bytes() -> bytes: + """Return the canonical generated catalog bytes used by contract snapshots.""" + + payload = { + "api_version": "v1", + "schema_version": 1, + "operations": [ + { + "name": operation.name, + "capability": operation.capability, + "transports": sorted(transport.value for transport in operation.transports), + "mutates_knowledge": operation.mutates_knowledge, + } + for operation in OPERATIONS + ], + } + return (json.dumps(payload, indent=2, sort_keys=True) + "\n").encode("utf-8") + + +def catalog_digest() -> str: + """Hash the same bytes written by the schema exporter.""" + + return hashlib.sha256(catalog_bytes()).hexdigest() diff --git a/src/gewu/domain/validation.py b/src/gewu/domain/validation.py new file mode 100644 index 0000000..b140a94 --- /dev/null +++ b/src/gewu/domain/validation.py @@ -0,0 +1,695 @@ +"""Deterministic proposal and evidence validation rules.""" + +from __future__ import annotations + +import hashlib +import json +import unicodedata +from collections.abc import Iterable +from dataclasses import dataclass + +from gewu.domain.contracts import ( + AutoAcceptPolicy, + ErrorCode, + EvidenceReference, + ExtractionBlock, + ExtractionRecord, + Finding, + KnowledgeNode, + Locator, + ProjectRecord, + ProjectStatus, + ProposalRevision, + SourceRevisionRecord, + SupportStatus, + TypedEdge, +) + + +def canonical_json(value: object) -> bytes: + """Serialize contract data deterministically for digests and replay checks.""" + + return json.dumps( + value, + ensure_ascii=False, + allow_nan=False, + sort_keys=True, + separators=(",", ":"), + ).encode("utf-8") + + +def assertion_digest(text: str) -> str: + normalized = unicodedata.normalize("NFC", text.replace("\r\n", "\n").replace("\r", "\n")) + return hashlib.sha256(canonical_json({"assertion": normalized})).hexdigest() + + +def evidence_digest(evidence: EvidenceReference) -> str: + """Hash a typed evidence reference using its public JSON representation.""" + + return hashlib.sha256(canonical_json(evidence.model_dump(mode="json"))).hexdigest() + + +def extraction_digest(extraction: ExtractionRecord) -> str: + value = extraction.model_dump(mode="json", exclude={"extraction_id"}) + return hashlib.sha256(canonical_json(value)).hexdigest() + + +def proposal_payload_digest(proposal: ProposalRevision) -> str: + operations = [item.model_dump(mode="json") for item in proposal.operations] + return hashlib.sha256(canonical_json(operations)).hexdigest() + + +def edge_assertion_digest(edge: TypedEdge) -> str: + """Bind edge evidence to the relationship, conditions and rationale.""" + + value = edge.model_dump(mode="json", exclude={"edge_id", "evidence"}) + return hashlib.sha256(canonical_json(value)).hexdigest() + + +def _locator_matches_block(locator: Locator, block: ExtractionBlock) -> bool: + if locator.kind == "pdf_page_span": + return ( + locator.page == block.page and block.time_start_ms is None and block.time_end_ms is None + ) + if locator.kind == "transcript_timestamp_span": + return ( + locator.page is None + and locator.time_start_ms == block.time_start_ms + and locator.time_end_ms == block.time_end_ms + ) + return ( + locator.page is None + and locator.time_start_ms is None + and locator.time_end_ms is None + and block.page is None + and block.time_start_ms is None + and block.time_end_ms is None + ) + + +@dataclass(frozen=True, slots=True) +class ValidationReport: + status: str + eligible_for_auto_accept: bool + findings: tuple[Finding, ...] + affected_policy_ids: tuple[str, ...] + + +def _finding(code: str, message: str, path: str | None = None) -> Finding: + return Finding(code=code, message=message, path=path) + + +def _entity_names(node: KnowledgeNode) -> set[str]: + return { + unicodedata.normalize("NFC", " ".join(value.split())).casefold() + for value in (node.title, *node.aliases) + if value.strip() + } + + +def _entity_visible( + candidate: KnowledgeNode, + existing: KnowledgeNode, + readable_project_ids: frozenset[str], +) -> bool: + return ( + existing.project_id == candidate.project_id or existing.project_id in readable_project_ids + ) + + +def validate_proposal( + proposal: ProposalRevision, + *, + projects: dict[str, ProjectRecord], + policies: dict[str, AutoAcceptPolicy], + sources: dict[str, SourceRevisionRecord], + extractions: dict[str, ExtractionRecord], + existing_nodes: dict[str, KnowledgeNode], + current_base_commit: str, + readable_project_ids: frozenset[str] = frozenset(), +) -> ValidationReport: + """Validate evidence, scope, relationships, policy and replay bindings.""" + + findings: list[Finding] = [] + affected_policy_ids: set[str] = set() + eligibility_checks: list[tuple[tuple[str, ...], str, str]] = [] + scoped_projects = set(projects) if proposal.scope.all_projects else set(proposal.scope.projects) + if proposal.scope.include_shared: + scoped_projects.add("shared") + comparison_project_ids = readable_project_ids & frozenset(scoped_projects) + if ( + not proposal.scope.all_projects + and proposal.initiating_project_id != "shared" + and proposal.initiating_project_id not in proposal.scope.projects + ): + findings.append( + _finding( + "OUT_OF_SCOPE", "Initiating project is outside explicit scope", "scope.projects" + ) + ) + known_sources = dict(sources) + proposed_nodes = { + operation.node.node_id: operation.node + for operation in proposal.operations + if operation.node is not None + } + known_nodes = {**existing_nodes, **proposed_nodes} + entity_nodes_seen: list[KnowledgeNode] = [] + + if proposal.base_commit != current_base_commit: + findings.append(_finding("BASE_CONFLICT", "Canonical base revision has changed")) + if proposal_payload_digest(proposal) != proposal.payload_digest: + findings.append(_finding("PAYLOAD_DIGEST_MISMATCH", "Proposal payload digest is incorrect")) + + for index, operation in enumerate(proposal.operations): + path = f"operations[{index}]" + if operation.source is not None: + source = operation.source + previous = known_sources.get(source.revision_id) + if previous and previous != source: + findings.append( + _finding( + "SOURCE_REVISION_CONFLICT", "Source revision ID is already bound", path + ) + ) + known_sources[source.revision_id] = source + source_domains = {"shared", *source.ingested_for} + for project_id in source.ingested_for: + if project_id != "shared" and project_id not in projects: + findings.append( + _finding( + "PROJECT_NOT_REGISTERED", + "Source relevance names an unregistered project", + f"{path}.source.ingested_for", + ) + ) + if ( + project_id != "shared" + and project_id not in proposal.scope.projects + and not proposal.scope.all_projects + ): + findings.append( + _finding( + "OUT_OF_SCOPE", + "Source relevance names a project outside explicit scope", + f"{path}.source.ingested_for", + ) + ) + affected_policy_ids.update(source_domains) + eligibility_checks.append((tuple(sorted(source_domains)), "source", "register_source")) + + if operation.node is not None: + node = operation.node + eligibility_checks.append(((node.project_id,), "node", node.node_type.value)) + if node.node_id in existing_nodes: + findings.append( + _finding( + "NODE_ID_EXISTS", + "Node IDs are stable and cannot be reused", + f"{path}.node.node_id", + ) + ) + if node.node_type.value == "entity": + possible_duplicates = [ + other + for other in (*existing_nodes.values(), *entity_nodes_seen) + if other.node_id != node.node_id + and other.node_type.value == "entity" + and _entity_visible(node, other, comparison_project_ids) + and _entity_names(node) & _entity_names(other) + ] + if possible_duplicates: + findings.append( + _finding( + "POSSIBLE_DUPLICATE_ENTITY", + "Entity name or alias matches an in-scope entity; review provenance " + "before linking or merging", + f"{path}.node", + ) + ) + entity_nodes_seen.append(node) + if node.project_id != "shared" and node.project_id not in projects: + findings.append( + _finding( + "PROJECT_NOT_REGISTERED", + "Node project is not registered", + f"{path}.node.project_id", + ) + ) + affected_policy_ids.add(node.project_id) + if node.agent_id != proposal.proposer_id: + findings.append( + _finding( + "AGENT_ATTRIBUTION_MISMATCH", + "Node attribution must match the authenticated proposal author", + f"{path}.node.agent_id", + ) + ) + claim_items = ( + (group_name, claim_index, claim) + for group_name, claims in ( + ("claims", node.claims), + ("assumptions", node.assumptions), + ("limitations", node.limitations), + ) + for claim_index, claim in enumerate(claims) + ) + for group_name, claim_index, claim in claim_items: + claim_path = f"{path}.node.{group_name}[{claim_index}]" + if claim.assessment.assessor_id != proposal.proposer_id: + findings.append( + _finding( + "ASSESSOR_ATTRIBUTION_MISMATCH", + "Support assessment must be attributed to the proposal author", + f"{claim_path}.assessment.assessor_id", + ) + ) + expected_assertion_digest = assertion_digest(claim.text) + assessments = {claim.assessment.assertion_digest} + if assessments != {expected_assertion_digest}: + findings.append( + _finding( + "ASSESSMENT_STALE", + "Support assessment does not bind the current claim text", + claim_path, + ) + ) + expected_evidence_digests = { + evidence_digest(evidence) for evidence in claim.evidence + } + if set(claim.assessment.evidence_digests) != expected_evidence_digests: + findings.append( + _finding( + "ASSESSMENT_EVIDENCE_MISMATCH", + "Support assessment does not bind the current evidence", + claim_path, + ) + ) + if claim.assessment.status != SupportStatus.SUPPORTED: + findings.append( + _finding( + "SUPPORT_UNAVAILABLE", + "Claim lacks a supported semantic assessment", + claim_path, + ) + ) + for evidence_index, evidence in enumerate(claim.evidence): + evidence_path = f"{claim_path}.evidence[{evidence_index}]" + expected_assertion_path = f"{group_name}[{claim_index}].text" + if evidence.assertion_path != expected_assertion_path: + findings.append( + _finding( + "EVIDENCE_ASSERTION_PATH_MISMATCH", + "Evidence assertion path does not match its typed claim group", + evidence_path, + ) + ) + if evidence.assertion_digest != expected_assertion_digest: + findings.append( + _finding( + "EVIDENCE_ASSERTION_MISMATCH", + "Evidence is not bound to the current claim text", + evidence_path, + ) + ) + source_record = known_sources.get(evidence.source_revision_id) + if source_record is None: + findings.append( + _finding( + "SOURCE_REVISION_MISSING", + "Evidence source revision is unavailable", + evidence_path, + ) + ) + extraction = extractions.get(evidence.extraction_id) + if ( + extraction is None + or extraction.source_revision_id != evidence.source_revision_id + ): + findings.append( + _finding( + "EXTRACTION_MISMATCH", + "Evidence extraction does not belong to its source revision", + evidence_path, + ) + ) + continue + if extraction_digest(extraction) != extraction.extraction_id: + findings.append( + _finding( + "EXTRACTION_HASH_MISMATCH", + "Stored extraction does not match its immutable identity", + evidence_path, + ) + ) + continue + if source_record and source_record.archive_status != "archived": + findings.append( + _finding( + "SOURCE_NOT_ARCHIVED", + "Reference-only sources cannot support accepted factual claims", + evidence_path, + ) + ) + block = next( + ( + item + for item in extraction.blocks + if item.block_id == evidence.locator.block_id + ), + None, + ) + if block is None: + findings.append( + _finding( + "EVIDENCE_LOCATION_MISMATCH", + "Evidence block does not exist", + evidence_path, + ) + ) + continue + if not _locator_matches_block(evidence.locator, block): + findings.append( + _finding( + "EVIDENCE_LOCATION_MISMATCH", + "Evidence locator metadata does not match its source block", + evidence_path, + ) + ) + continue + if evidence.locator.end > len(block.normalized_text): + findings.append( + _finding( + "EVIDENCE_LOCATION_MISMATCH", + "Evidence span extends beyond its source block", + evidence_path, + ) + ) + continue + located_text = block.normalized_text[ + evidence.locator.start : evidence.locator.end + ] + normalized_quote = unicodedata.normalize( + "NFC", evidence.quote.replace("\r\n", "\n").replace("\r", "\n") + ) + location_map = block.normalization_map[ + evidence.locator.start : evidence.locator.end + ] + original_start = min((span[0] for span in location_map), default=-1) + original_end = max((span[1] for span in location_map), default=-1) + original_quote = ( + block.original_text[original_start:original_end] + if original_start >= 0 + else "" + ) + normalized_original_quote = unicodedata.normalize( + "NFC", original_quote.replace("\r\n", "\n").replace("\r", "\n") + ) + if ( + located_text != normalized_quote + or normalized_original_quote != normalized_quote + ): + findings.append( + _finding( + ErrorCode.EVIDENCE_MISMATCH.value, + "Quote does not match text at the stated location", + evidence_path, + ) + ) + + if operation.edge is not None: + edge = operation.edge + expected_edge_digest = edge_assertion_digest(edge) + for evidence_index, evidence in enumerate(edge.evidence): + evidence_path = f"{path}.edge.evidence[{evidence_index}]" + if evidence.assertion_digest != expected_edge_digest: + findings.append( + _finding( + "EVIDENCE_ASSERTION_MISMATCH", + "Evidence is not bound to the current relationship", + evidence_path, + ) + ) + source_record = known_sources.get(evidence.source_revision_id) + extraction = extractions.get(evidence.extraction_id) + if source_record is None: + findings.append( + _finding( + "SOURCE_REVISION_MISSING", + "Evidence source revision is unavailable", + evidence_path, + ) + ) + if ( + extraction is None + or extraction.source_revision_id != evidence.source_revision_id + ): + findings.append( + _finding( + "EXTRACTION_MISMATCH", + "Evidence extraction does not belong to its source revision", + evidence_path, + ) + ) + continue + if extraction_digest(extraction) != extraction.extraction_id: + findings.append( + _finding( + "EXTRACTION_HASH_MISMATCH", + "Stored extraction does not match its immutable identity", + evidence_path, + ) + ) + continue + if source_record and source_record.archive_status != "archived": + findings.append( + _finding( + "SOURCE_NOT_ARCHIVED", + "Reference-only sources cannot support accepted relationships", + evidence_path, + ) + ) + block = next( + ( + item + for item in extraction.blocks + if item.block_id == evidence.locator.block_id + ), + None, + ) + if block is None or not _locator_matches_block(evidence.locator, block): + findings.append( + _finding( + "EVIDENCE_LOCATION_MISMATCH", + "Evidence block or locator metadata does not match", + evidence_path, + ) + ) + continue + if evidence.locator.end > len(block.normalized_text): + findings.append( + _finding( + "EVIDENCE_LOCATION_MISMATCH", + "Evidence span extends beyond its source block", + evidence_path, + ) + ) + continue + actual_quote = block.normalized_text[evidence.locator.start : evidence.locator.end] + if actual_quote != unicodedata.normalize( + "NFC", evidence.quote.replace("\\r\\n", "\\n").replace("\\r", "\\n") + ): + findings.append( + _finding( + ErrorCode.EVIDENCE_MISMATCH.value, + "Quote does not match text at the stated location", + evidence_path, + ) + ) + source_node = known_nodes.get(edge.source.id) if edge.source.kind == "node" else None + target_node = known_nodes.get(edge.target.id) if edge.target.kind == "node" else None + edge_domains: set[str] = set() + for endpoint, node_endpoint, label in ( + (edge.source, source_node, "source"), + (edge.target, target_node, "target"), + ): + if endpoint.kind == "node": + if node_endpoint is None: + findings.append( + _finding( + "EDGE_TARGET_MISSING", + f"Edge {label} node {endpoint.id} is unavailable", + f"{path}.edge.{label}.id", + ) + ) + else: + affected_policy_ids.add(node_endpoint.project_id) + edge_domains.add(node_endpoint.project_id) + elif endpoint.kind == "source": + if endpoint.id not in known_sources and endpoint.id not in { + source.revision_id + for source in ( + item.source for item in proposal.operations if item.source is not None + ) + }: + findings.append( + _finding( + "EDGE_SOURCE_MISSING", + f"Edge source revision {endpoint.id} is unavailable", + f"{path}.edge.{label}.id", + ) + ) + affected_policy_ids.add("shared") + edge_domains.add("shared") + elif endpoint.id not in projects: + findings.append( + _finding( + "EDGE_PROJECT_MISSING", + f"Edge project {endpoint.id} is not registered", + f"{path}.edge.{label}.id", + ) + ) + else: + affected_policy_ids.add(endpoint.id) + edge_domains.add(endpoint.id) + if edge_domains: + eligibility_checks.append((tuple(sorted(edge_domains)), "edge", edge.edge_type)) + if edge.edge_type in {"solves", "addresses"} and source_node and target_node: + if ( + source_node.node_type.value != "technique" + or target_node.node_type.value != "problem" + ): + findings.append( + _finding( + "EDGE_TYPE_MISMATCH", + f"{edge.edge_type} must connect a technique to a problem", + f"{path}.edge", + ) + ) + + if operation.target_id is not None: + target = existing_nodes.get(operation.target_id) + if target is None: + findings.append( + _finding( + "NODE_NOT_FOUND", "Staleness target does not exist", f"{path}.target_id" + ) + ) + else: + affected_policy_ids.add(target.project_id) + eligibility_checks.append(((target.project_id,), "stale", "flag_stale")) + + policy_by_domain: dict[str, AutoAcceptPolicy] = {} + for project_id in sorted(affected_policy_ids): + project = projects.get(project_id) + if project and project.status != ProjectStatus.ACTIVE: + findings.append( + _finding( + "PROJECT_NOT_ACTIVE", + f"Project {project_id} is {project.status.value} and cannot accept changes", + ) + ) + policy_id = project.autonomy_policy_id if project else project_id + policy = policies.get(policy_id) + if policy is None or policy.project_id != project_id: + findings.append( + _finding("POLICY_MISSING", f"No applicable policy is registered for {project_id}") + ) + continue + if not policy.require_supported_assessment: + findings.append( + _finding( + "POLICY_WEAKENING", + "Policy cannot waive required claim support assessments", + f"policies.{policy_id}.require_supported_assessment", + ) + ) + policy_by_domain[project_id] = policy + if project_id == "shared" and not proposal.scope.include_shared: + findings.append( + _finding( + "OUT_OF_SCOPE", "Proposal changes shared outside its explicit scope", "scope" + ) + ) + elif project_id != "shared" and project_id not in proposal.scope.projects: + findings.append( + _finding( + "OUT_OF_SCOPE", + "Proposal changes a project outside its explicit scope", + "scope.projects", + ) + ) + + automatic = bool(policy_by_domain) and len(policy_by_domain) == len(affected_policy_ids) + for domains, change_kind, change_type in eligibility_checks: + applicable = [policy_by_domain.get(project_id) for project_id in domains] + if any(policy is None for policy in applicable): + automatic = False + elif change_kind == "source": + automatic = automatic and all( + bool(policy and policy.automatic_source_registration) for policy in applicable + ) + elif change_kind == "node": + automatic = automatic and all( + bool(policy and change_type in policy.automatic_node_types) for policy in applicable + ) + elif change_kind == "edge": + automatic = automatic and all( + bool(policy and change_type in policy.automatic_edge_types) for policy in applicable + ) + else: + automatic = False + if not automatic and not findings: + findings.append( + _finding( + "AUTO_ACCEPT_UNSPECIFIED", "Applicable policy requires an exceptional decision" + ) + ) + + blocking_codes = {"BASE_CONFLICT", "PAYLOAD_DIGEST_MISMATCH"} + invalid_codes = { + "SOURCE_REVISION_CONFLICT", + "PROJECT_NOT_REGISTERED", + "NODE_ID_EXISTS", + "ASSESSMENT_STALE", + "ASSESSMENT_EVIDENCE_MISMATCH", + "SUPPORT_UNAVAILABLE", + "EVIDENCE_ASSERTION_MISMATCH", + "SOURCE_REVISION_MISSING", + "EXTRACTION_MISMATCH", + "EXTRACTION_HASH_MISMATCH", + "SOURCE_NOT_ARCHIVED", + "EVIDENCE_LOCATION_MISMATCH", + ErrorCode.EVIDENCE_MISMATCH.value, + "EDGE_TARGET_MISSING", + "EDGE_SOURCE_MISSING", + "EDGE_PROJECT_MISSING", + "EDGE_TYPE_MISMATCH", + "NODE_NOT_FOUND", + "POLICY_WEAKENING", + "OUT_OF_SCOPE", + } + codes = {item.code for item in findings} + if codes & blocking_codes: + status = "conflict" + elif codes & invalid_codes: + status = "invalid" + elif findings: + status = "needs-review" + else: + status = "eligible" + return ValidationReport( + status=status, + eligible_for_auto_accept=status == "eligible" and automatic, + findings=tuple(findings), + affected_policy_ids=tuple(sorted(policy.policy_id for policy in policy_by_domain.values())), + ) + + +def unique_findings(findings: Iterable[Finding]) -> tuple[Finding, ...]: + """Stable deduplication for findings shared across bundle members.""" + + result: dict[tuple[str, str, str | None], Finding] = {} + for item in findings: + result[(item.code, item.message, item.path)] = item + return tuple(result.values()) diff --git a/src/gewu/transports/__init__.py b/src/gewu/transports/__init__.py new file mode 100644 index 0000000..1738de8 --- /dev/null +++ b/src/gewu/transports/__init__.py @@ -0,0 +1 @@ +"""Wire adapters for HTTP and MCP; business rules stay in application services.""" diff --git a/src/gewu/transports/accounts_http.py b/src/gewu/transports/accounts_http.py new file mode 100644 index 0000000..21389b5 --- /dev/null +++ b/src/gewu/transports/accounts_http.py @@ -0,0 +1,273 @@ +"""Password sign-in and human-owned agent credential management.""" + +import sqlite3 +from collections.abc import Callable +from typing import Annotated, Literal + +from fastapi import Cookie, Depends, FastAPI, Header, HTTPException, Query, Request, Response +from fastapi.exceptions import RequestValidationError +from pydantic import BaseModel, ConfigDict, Field, model_validator +from starlette.responses import JSONResponse + +from gewu.application.accounts import AccountIdentity, AccountSummary +from gewu.application.admin import CredentialSummary +from gewu.application.identity import Principal +from gewu.domain.contracts import RecordId +from gewu.transports.admin_http import IssuedCredential + +Username = Annotated[str, Field(pattern=r"^[a-z0-9][a-z0-9._-]{0,63}$")] +Password = Annotated[str, Field(min_length=12, max_length=1024)] + + +class LoginBody(BaseModel): + model_config = ConfigDict(extra="forbid", strict=True) + username: Username + password: Annotated[str, Field(min_length=1, max_length=1024)] + + +class AccountCreateBody(LoginBody): + password: Password + role: Literal["viewer", "editor", "admin"] = "editor" + project_ids: list[RecordId] = Field(default_factory=list, max_length=50) + + @model_validator(mode="after") + def unique_grants(self) -> "AccountCreateBody": + if len(self.project_ids) != len(set(self.project_ids)): + raise ValueError("Project grants must be unique") + return self + + +class AccountUpdateBody(BaseModel): + model_config = ConfigDict(extra="forbid", strict=True) + role: Literal["viewer", "editor", "admin"] + project_ids: list[RecordId] = Field(default_factory=list, max_length=50) + + @model_validator(mode="after") + def unique_grants(self) -> "AccountUpdateBody": + if len(self.project_ids) != len(set(self.project_ids)): + raise ValueError("Project grants must be unique") + return self + + +class PasswordChangeBody(BaseModel): + model_config = ConfigDict(extra="forbid", strict=True) + current_password: Annotated[str, Field(min_length=1, max_length=1024)] + new_password: Password + + +class AgentCreateBody(BaseModel): + model_config = ConfigDict(extra="forbid", strict=True) + name: str = Field(pattern=r"^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$") + project_ids: list[RecordId] = Field(min_length=1, max_length=50) + + +class CurrentUser(BaseModel): + principal_id: str + roles: list[str] + project_ids: list[str] + password_account: bool + + +def register_account_routes( + app: FastAPI, + store: AccountIdentity, + authenticate: Callable[..., Principal], + require_same_origin: Callable[[Request], None], +) -> None: + @app.exception_handler(RequestValidationError) + async def validation_error(request: Request, error: RequestValidationError) -> JSONResponse: + # Passwords must not be echoed in FastAPI's default validation error input fields. + sensitive = request.url.path in { + "/v1/users/session", + "/v1/admin/users", + "/v1/me/password", + } + if sensitive: + return JSONResponse( + status_code=422, + content={"detail": "Check the username, password and account fields"}, + headers={"Cache-Control": "no-store"}, + ) + from fastapi.exception_handlers import request_validation_exception_handler + + return await request_validation_exception_handler(request, error) + + def human( + request: Request, + response: Response, + authorization: str | None = Header(default=None), + session_cookie: str | None = Cookie(default=None, alias="gewu_session"), + csrf_token: str | None = Header(default=None, alias="X-GEWU-CSRF"), + ) -> Principal: + response.headers["Cache-Control"] = "private, no-store" + write = request.method not in {"GET", "HEAD"} + if write: + require_same_origin(request) + principal = authenticate(authorization, session_cookie, csrf_token, require_csrf=write) + if "agent" in principal.roles: + raise HTTPException(403, "A human account is required") + return principal + + def admin(principal: Annotated[Principal, Depends(human)]) -> Principal: + if "admin" not in principal.roles: + raise HTTPException(403, "Administrator role is required") + return principal + + def account_error(error: Exception) -> HTTPException: + if isinstance(error, PermissionError): + return HTTPException(403, str(error)) + if isinstance(error, sqlite3.IntegrityError): + return HTTPException(409, "Username already exists") + return HTTPException(422, "Account or agent credential fields are invalid") + + @app.post("/v1/users/session", tags=["identity"], operation_id="account_login") + def login(body: LoginBody, request: Request, response: Response) -> dict[str, str]: + require_same_origin(request) + if request.url.scheme != "https" and request.url.hostname not in { + "localhost", + "127.0.0.1", + "::1", + }: + raise HTTPException(403, "Password sign-in requires HTTPS") + session = store.login_account(body.username, body.password) + if session is None: + raise HTTPException( + 401, + "Sign-in failed. Check your username and password, or try again later.", + headers={"Cache-Control": "no-store"}, + ) + response.headers["Cache-Control"] = "no-store" + response.set_cookie( + "gewu_session", + session.session_token, + httponly=True, + secure=request.url.scheme == "https", + samesite="strict", + path="/v1", + ) + return {"csrf_token": session.csrf_token, "expires_at": session.expires_at.isoformat()} + + @app.get("/v1/me", tags=["identity"], operation_id="account_current") + def me(principal: Annotated[Principal, Depends(human)]) -> CurrentUser: + return CurrentUser( + principal_id=principal.principal_id, + roles=sorted(principal.roles), + project_ids=sorted(principal.project_ids), + password_account=principal.credential_id.startswith("account:"), + ) + + @app.post("/v1/me/password", tags=["identity"], operation_id="account_change_password") + def change_password( + body: PasswordChangeBody, + response: Response, + principal: Annotated[Principal, Depends(human)], + ) -> dict[str, str]: + try: + changed = store.change_account_password( + principal, body.current_password, body.new_password + ) + except (PermissionError, ValueError) as error: + raise account_error(error) from error + if not changed: + raise HTTPException(403, "Current password is incorrect") + response.delete_cookie("gewu_session", path="/v1") + return {"status": "password_changed", "next": "sign_in"} + + @app.get("/v1/admin/users", tags=["control"], operation_id="admin_list_accounts") + def users( + principal: Annotated[Principal, Depends(admin)], + limit: int = Query(default=50, ge=1, le=100), + offset: int = Query(default=0, ge=0), + ) -> list[AccountSummary]: + return store.list_accounts(limit=limit, offset=offset) + + @app.post( + "/v1/admin/users", status_code=201, tags=["control"], operation_id="admin_create_account" + ) + def create_user( + body: AccountCreateBody, principal: Annotated[Principal, Depends(admin)] + ) -> AccountSummary: + try: + return store.create_account( + username=body.username, + password=body.password, + role=body.role, + project_ids=tuple(body.project_ids), + actor=principal.principal_id, + ) + except (sqlite3.IntegrityError, ValueError) as error: + raise account_error(error) from error + + @app.patch("/v1/admin/users/{username}", tags=["control"], operation_id="admin_update_account") + def update_user( + username: Username, body: AccountUpdateBody, principal: Annotated[Principal, Depends(admin)] + ) -> AccountSummary: + try: + result = store.update_account( + username, role=body.role, project_ids=tuple(body.project_ids), actor=principal + ) + except (PermissionError, ValueError) as error: + raise account_error(error) from error + if result is None: + raise HTTPException(404, "Active account was not found") + return result + + @app.delete( + "/v1/admin/users/{username}", tags=["control"], operation_id="admin_disable_account" + ) + def disable_user( + username: Username, principal: Annotated[Principal, Depends(admin)] + ) -> dict[str, str]: + try: + disabled = store.disable_account(username, actor=principal) + except PermissionError as error: + raise account_error(error) from error + if not disabled: + raise HTTPException(404, "Active account was not found") + return {"status": "disabled"} + + @app.get( + "/v1/me/agent-credentials", tags=["identity"], operation_id="account_list_agent_credentials" + ) + def agents( + principal: Annotated[Principal, Depends(human)], + limit: int = Query(default=50, ge=1, le=100), + offset: int = Query(default=0, ge=0), + ) -> list[CredentialSummary]: + try: + return store.list_agent_credentials(principal, limit=limit, offset=offset) + except PermissionError as error: + raise account_error(error) from error + + @app.post( + "/v1/me/agent-credentials", + status_code=201, + tags=["identity"], + operation_id="account_create_agent_credential", + ) + def create_agent( + body: AgentCreateBody, principal: Annotated[Principal, Depends(human)] + ) -> IssuedCredential: + try: + key_id, token = store.create_agent_credential( + principal, name=body.name, project_ids=tuple(body.project_ids) + ) + except (PermissionError, ValueError) as error: + raise account_error(error) from error + return IssuedCredential(credential_id=key_id, token=token) + + @app.delete( + "/v1/me/agent-credentials/{credential_id}", + tags=["identity"], + operation_id="account_revoke_agent_credential", + ) + def revoke_agent( + credential_id: RecordId, principal: Annotated[Principal, Depends(human)] + ) -> dict[str, str]: + try: + revoked = store.revoke_agent_credential(principal, credential_id) + except PermissionError as error: + raise account_error(error) from error + if not revoked: + raise HTTPException(404, "Active agent credential was not found") + return {"status": "revoked"} diff --git a/src/gewu/transports/admin_http.py b/src/gewu/transports/admin_http.py new file mode 100644 index 0000000..7bd0142 --- /dev/null +++ b/src/gewu/transports/admin_http.py @@ -0,0 +1,152 @@ +"""Human administrator credential controls; browser writes require CSRF.""" + +import sqlite3 +from collections.abc import Callable +from typing import Annotated, Literal + +from fastapi import Cookie, Depends, FastAPI, Header, HTTPException, Query, Request, Response +from pydantic import BaseModel, ConfigDict, Field, model_validator + +from gewu.application.admin import AdminEvent, CredentialAdministration, CredentialSummary +from gewu.application.identity import Principal +from gewu.domain.contracts import RecordId + + +class CredentialCreateBody(BaseModel): + model_config = ConfigDict(extra="forbid", strict=True) + + credential_id: RecordId + principal_id: RecordId + role: Literal["viewer", "editor", "agent", "admin"] + project_ids: list[RecordId] = Field(default_factory=list, max_length=50) + + @model_validator(mode="after") + def unique_projects(self) -> "CredentialCreateBody": + if len(self.project_ids) != len(set(self.project_ids)): + raise ValueError("Project grants must be unique") + if self.role != "admin" and not self.project_ids: + raise ValueError("Select at least one project for this credential") + return self + + +class CredentialUpdateBody(BaseModel): + model_config = ConfigDict(extra="forbid", strict=True) + role: Literal["viewer", "editor", "agent", "admin"] + project_ids: list[RecordId] = Field(default_factory=list, max_length=50) + + @model_validator(mode="after") + def unique_projects(self) -> "CredentialUpdateBody": + if len(self.project_ids) != len(set(self.project_ids)): + raise ValueError("Project grants must be unique") + if self.role != "admin" and not self.project_ids: + raise ValueError("Select at least one project for this credential") + return self + + +class IssuedCredential(BaseModel): + credential_id: str + token: str + + +def register_admin_routes( + app: FastAPI, + store: CredentialAdministration, + authenticate: Callable[..., Principal], + require_same_origin: Callable[[Request], None], +) -> None: + def admin( + request: Request, + response: Response, + authorization: str | None = Header(default=None), + session_cookie: str | None = Cookie(default=None, alias="gewu_session"), + csrf_token: str | None = Header(default=None, alias="X-GEWU-CSRF"), + ) -> Principal: + response.headers["Cache-Control"] = "private, no-store" + write = request.method not in {"GET", "HEAD"} + if write: + require_same_origin(request) + principal = authenticate(authorization, session_cookie, csrf_token, require_csrf=write) + if "admin" not in principal.roles or "agent" in principal.roles: + raise HTTPException(403, "Administrator role is required") + return principal + + @app.get("/v1/admin/credentials", tags=["control"], operation_id="admin_list_credentials") + def credentials( + principal: Annotated[Principal, Depends(admin)], + limit: int = Query(default=50, ge=1, le=100), + offset: int = Query(default=0, ge=0), + ) -> list[CredentialSummary]: + return store.list_credentials(limit=limit, offset=offset) + + @app.post( + "/v1/admin/credentials", + status_code=201, + tags=["control"], + operation_id="admin_create_credential", + ) + def create( + body: CredentialCreateBody, principal: Annotated[Principal, Depends(admin)] + ) -> IssuedCredential: + try: + token = store.provision( + credential_id=body.credential_id, + principal_id=body.principal_id, + roles=(body.role,), + project_ids=tuple(body.project_ids), + actor=principal.principal_id, + ) + except sqlite3.IntegrityError as error: + raise HTTPException(409, "Credential ID already exists") from error + except ValueError as error: + raise HTTPException(422, "Credential identity or grants are invalid") from error + return IssuedCredential(credential_id=body.credential_id, token=token) + + @app.patch( + "/v1/admin/credentials/{credential_id}", + tags=["control"], + operation_id="admin_update_credential", + ) + def update_credential( + credential_id: RecordId, + body: CredentialUpdateBody, + principal: Annotated[Principal, Depends(admin)], + ) -> CredentialSummary: + if credential_id == principal.credential_id: + raise HTTPException( + 409, "Use another administrator credential to change this credential" + ) + try: + result = store.update_credential( + credential_id, + role=body.role, + project_ids=tuple(body.project_ids), + actor=principal.principal_id, + ) + except ValueError as error: + raise HTTPException(422, "Credential role or grants are invalid") from error + if result is None: + raise HTTPException(404, "Active credential was not found") + return result + + @app.delete( + "/v1/admin/credentials/{credential_id}", + tags=["control"], + operation_id="admin_revoke_credential", + ) + def revoke( + credential_id: RecordId, principal: Annotated[Principal, Depends(admin)] + ) -> dict[str, str]: + if credential_id == principal.credential_id: + raise HTTPException( + 409, "Use another administrator credential to revoke this session's key" + ) + if not store.revoke(credential_id, actor=principal.principal_id): + raise HTTPException(404, "Active credential was not found") + return {"status": "revoked"} + + @app.get("/v1/admin/events", tags=["control"], operation_id="admin_list_events") + def events( + principal: Annotated[Principal, Depends(admin)], + limit: int = Query(default=50, ge=1, le=100), + ) -> list[AdminEvent]: + return store.list_admin_events(limit=limit) diff --git a/src/gewu/transports/http_middleware.py b/src/gewu/transports/http_middleware.py new file mode 100644 index 0000000..06f7d51 --- /dev/null +++ b/src/gewu/transports/http_middleware.py @@ -0,0 +1,91 @@ +"""Transport-level request limits for small control-plane payloads.""" + +from __future__ import annotations + +from starlette.types import ASGIApp, Message, Receive, Scope, Send + +MAX_PROJECT_REGISTRY_BODY_BYTES = 64 * 1024 +MAX_OWNER_AUTH_BODY_BYTES = 4 * 1024 + + +class ProjectRegistryBodyLimitMiddleware: + """Bound small control-plane JSON bodies before FastAPI parses credentials or records.""" + + def __init__(self, app: ASGIApp) -> None: + self.app = app + + async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: + limits = { + "/v1/admin/projects": MAX_PROJECT_REGISTRY_BODY_BYTES, + "/auth/owner-session": MAX_OWNER_AUTH_BODY_BYTES, + "/v1/admin/credentials": MAX_OWNER_AUTH_BODY_BYTES, + "/v1/users/session": MAX_OWNER_AUTH_BODY_BYTES, + "/v1/admin/users": 8 * 1024, + "/v1/me/password": 8 * 1024, + "/v1/me/agent-credentials": MAX_OWNER_AUTH_BODY_BYTES, + } + path = scope.get("path") + if scope["type"] != "http" or scope.get("method") != "POST" or path not in limits: + await self.app(scope, receive, send) + return + limit = limits[path] + + content_length = next( + ( + value + for name, value in scope.get("headers", []) + if name.lower() == b"content-length" + ), + None, + ) + if content_length is not None: + try: + if int(content_length) > limit: + await self._too_large(send) + return + except ValueError: + await self._too_large(send) + return + + messages: list[Message] = [] + size = 0 + while True: + message = await receive() + messages.append(message) + if message["type"] == "http.disconnect": + return + if message["type"] == "http.request": + size += len(message.get("body", b"")) + if size > limit: + await self._too_large(send) + return + if not message.get("more_body", False): + break + + replay_index = 0 + + async def replay_receive() -> Message: + nonlocal replay_index + if replay_index < len(messages): + message = messages[replay_index] + replay_index += 1 + return message + return await receive() + + await self.app(scope, replay_receive, send) + + @staticmethod + async def _too_large(send: Send) -> None: + await send( + { + "type": "http.response.start", + "status": 413, + "headers": [(b"content-type", b"application/json")], + } + ) + await send( + { + "type": "http.response.body", + "body": b'{"detail":"Control-plane request exceeds its size limit"}', + } + ) diff --git a/src/gewu/transports/http_models.py b/src/gewu/transports/http_models.py new file mode 100644 index 0000000..024aacb --- /dev/null +++ b/src/gewu/transports/http_models.py @@ -0,0 +1,185 @@ +"""JSON-native request schemas for strict HTTP adapters.""" + +from __future__ import annotations + +import json +from datetime import date + +from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator + +from gewu.domain.contracts import ( + AutoAcceptPolicy, + Confidence, + NeighborRequest, + NodeType, + ProjectRecord, + RecordId, + SearchFacetValue, + SearchRequest, + SourceListRequest, +) + + +class SearchScopeBody(BaseModel): + model_config = ConfigDict(extra="forbid", strict=True, frozen=True) + + projects: list[RecordId] = Field(min_length=1, max_length=50) + include_projects: list[RecordId] | None = Field(default=None, max_length=50) + include_shared: bool = True + all_projects: bool = False + + +class SearchRequestBody(BaseModel): + model_config = ConfigDict(extra="forbid", strict=True, frozen=True) + + query: str = Field(default="", max_length=1000) + scope: SearchScopeBody + node_types: list[str] = Field(default_factory=list, max_length=9) + tags: list[SearchFacetValue] = Field(default_factory=list, max_length=20) + audience: list[SearchFacetValue] = Field(default_factory=list, max_length=20) + use_for: list[SearchFacetValue] = Field(default_factory=list, max_length=20) + domain: list[SearchFacetValue] = Field(default_factory=list, max_length=20) + confidences: list[Confidence] = Field(default_factory=list, max_length=3) + as_of_from: date | None = None + as_of_to: date | None = None + stale_only: bool = False + limit: int = Field(default=20, ge=1, le=50) + cursor: str | None = Field(default=None, max_length=512) + budget_tokens: int = Field(ge=256, le=50_000) + + @field_validator("query") + @classmethod + def normalize_query(cls, value: str) -> str: + return value.strip() + + @field_validator("confidences", mode="before") + @classmethod + def parse_confidences(cls, value: object) -> object: + if not isinstance(value, list): + return value + try: + return [Confidence(item) if isinstance(item, str) else item for item in value] + except ValueError as error: + raise ValueError("confidences contains an unsupported value") from error + + @field_validator("as_of_from", "as_of_to", mode="before") + @classmethod + def parse_as_of_dates(cls, value: object) -> object: + if value is None or isinstance(value, date): + return value + if not isinstance(value, str): + return value + try: + return date.fromisoformat(value) + except ValueError as error: + raise ValueError("as-of filters must use YYYY-MM-DD dates") from error + + @model_validator(mode="after") + def require_query_or_type_filter(self) -> SearchRequestBody: + if not self.query and not self.node_types: + raise ValueError("search requires text or at least one knowledge type") + return self + + @field_validator("node_types") + @classmethod + def validate_node_types(cls, values: list[str]) -> list[str]: + try: + normalized = [NodeType(value).value for value in values] + except ValueError as error: + raise ValueError("node_types contains an unsupported knowledge type") from error + if len(normalized) != len(set(normalized)): + raise ValueError("node_types must be unique") + return normalized + + def to_contract(self) -> SearchRequest: + return SearchRequest.model_validate_json(self.model_dump_json(), strict=True) + + +class NeighborRequestBody(BaseModel): + model_config = ConfigDict(extra="forbid", strict=True, frozen=True) + + node_id: RecordId + scope: SearchScopeBody + edge_types: list[str] = Field(default_factory=list, max_length=12) + limit: int = Field(default=20, ge=1, le=50) + cursor: str | None = Field(default=None, max_length=512) + budget_tokens: int = Field(ge=256, le=50_000) + + def to_contract(self) -> NeighborRequest: + return NeighborRequest.model_validate_json(self.model_dump_json(), strict=True) + + +class SourceListRequestBody(BaseModel): + model_config = ConfigDict(extra="forbid", strict=True, frozen=True) + + scope: SearchScopeBody + limit: int = Field(default=20, ge=1, le=50) + cursor: str | None = Field(default=None, max_length=512) + budget_tokens: int = Field(ge=256, le=50_000) + + def to_contract(self) -> SourceListRequest: + return SourceListRequest.model_validate_json(self.model_dump_json(), strict=True) + + +class OwnerSessionRequestBody(BaseModel): + """Short-lived owner authentication inputs; never accepted as a normal session.""" + + model_config = ConfigDict(extra="forbid", strict=True, frozen=True) + + principal_id: RecordId + password: str = Field(min_length=12, max_length=1024) + totp_code: str = Field(pattern=r"^\d{6}$") + challenge_id: RecordId + + +class ProjectContentUpdateBody(BaseModel): + """Editable human-authored project intent fields.""" + + model_config = ConfigDict(extra="forbid", strict=True, frozen=True) + description: str = Field(max_length=4000) + north_star: str = Field(min_length=1, max_length=4000) + key_questions: list[str] = Field(min_length=1, max_length=30) + + @field_validator("key_questions") + @classmethod + def questions_are_nonempty(cls, value: list[str]) -> list[str]: + if any(not question.strip() or len(question) > 1000 for question in value): + raise ValueError("Key questions must contain 1 to 1000 characters") + return [question.strip() for question in value] + + +class ProjectCreateBody(BaseModel): + """An administrator supplies the human-owned project lens and its initial policy.""" + + model_config = ConfigDict(extra="forbid", strict=True, frozen=True) + + project: ProjectRecord + policy: AutoAcceptPolicy + + @model_validator(mode="before") + @classmethod + def validate_nested_contracts(cls, value: object) -> object: + if not isinstance(value, dict): + return value + parsed = dict(value) + for field, record_type in ( + ("project", ProjectRecord), + ("policy", AutoAcceptPolicy), + ): + raw = parsed.get(field) + if isinstance(raw, dict): + parsed[field] = record_type.model_validate_json( + json.dumps(raw, ensure_ascii=False), strict=True + ) + return parsed + + @model_validator(mode="after") + def project_and_policy_match(self) -> ProjectCreateBody: + if ( + self.project.autonomy_policy_id != self.policy.policy_id + or self.project.project_id != self.policy.project_id + ): + raise ValueError("Project and initial policy identities must match") + if not self.project.owners: + raise ValueError("At least one named project owner is required") + return self diff --git a/src/gewu/transports/mcp_server.py b/src/gewu/transports/mcp_server.py new file mode 100644 index 0000000..fd8142e --- /dev/null +++ b/src/gewu/transports/mcp_server.py @@ -0,0 +1,1254 @@ +"""Authenticated MCP read and contribution operations.""" + +from __future__ import annotations + +import base64 +import binascii +import hashlib +import json +import re +from collections.abc import Callable, Iterator +from datetime import UTC, date, datetime +from typing import Literal +from urllib.parse import urlsplit, urlunsplit + +import portalocker +from mcp.server import MCPServer +from mcp.server.mcpserver import Context +from pydantic import ValidationError + +from gewu.adapters.pdf_extractor import PdfExtractionError +from gewu.adapters.source_extractor import SourceExtractor +from gewu.adapters.url_fetch import PublicUrlFetcher, UrlCaptureError +from gewu.application.acceptance import AcceptanceError, AcceptanceService, ActorContext +from gewu.application.context import ValidationContext +from gewu.application.diagnostics import build_service_diagnostics +from gewu.application.identity import IdentityProvider, Principal +from gewu.application.reading import CanonicalNodeReader, CanonicalSourceReader, ReadError +from gewu.application.runs import LocalRunService, RunError +from gewu.application.search import CanonicalSearchService, SearchError +from gewu.application.uploads import ( + CHUNK_BYTES, + LocalArtifactStore, + UploadError, +) +from gewu.domain.contracts import ( + Confidence, + EvidenceReference, + ExtractionRecord, + GitCommitId, + NeighborRequest, + NodeType, + ProjectContextResponse, + ProjectLens, + ProposalRevision, + ReliabilityGrade, + RunCheckpointRequest, + RunCreateRequest, + Scope, + SearchRequest, + SourceListRequest, + SourceRevisionRecord, +) +from gewu.domain.validation import validate_proposal +from gewu.version import SERVICE_VERSION + +MAX_PASTED_TEXT_BYTES = 1_000_000 +MAX_PROJECT_CONTEXT_BYTES = 64_000 + + +def _principal(ctx: Context, identities: IdentityProvider) -> Principal: + headers = ctx.headers or {} + authorization = next( + (value for key, value in headers.items() if key.lower() == "authorization"), "" + ) + scheme, _, token = authorization.partition(" ") + if scheme.lower() != "bearer" or not token: + raise PermissionError("A verified GEWU bearer credential is required") + principal = identities.authenticate(token) + if principal is None: + raise PermissionError("GEWU credential is invalid or revoked") + return principal + + +def create_mcp_server( + *, + identity_provider: IdentityProvider, + node_reader: CanonicalNodeReader, + source_reader: CanonicalSourceReader | None = None, + search_service: CanonicalSearchService | None = None, + artifact_store: LocalArtifactStore | None = None, + extractor: SourceExtractor | None = None, + acceptance_service: AcceptanceService | None = None, + context_provider: Callable[[], ValidationContext] | None = None, + url_fetcher: PublicUrlFetcher | None = None, +) -> MCPServer: + """Create the allowlisted MCP server for the supplied application services.""" + + if url_fetcher is None: + url_fetcher = PublicUrlFetcher(frozenset()) + server = MCPServer( + "GEWU", + version=SERVICE_VERSION, + instructions="Read accepted GEWU knowledge by explicit project and node ID.", + ) + available_operations = {"diagnostics", "get_node", "node_history"} + available_operations.add("source_capture_url") + + @server.tool(name="diagnostics", structured_output=False) + def diagnostics( + ctx: Context, + projects: list[str] | None = None, + include_shared: bool = False, + ) -> str: + """Report authenticated caller scope, service contracts, limits and index state.""" + + principal = _principal(ctx, identity_provider) + authorized_projects = ( + search_service.list_projects(principal=principal) + if search_service is not None + else tuple( + sorted( + project + for project in principal.project_ids + if principal.can_read_project(project) + ) + ) + ) + index_status = ( + search_service.diagnostic_index_status() + if search_service is not None + else (node_reader.repository.head(), None, "not_configured", None) + ) + try: + report = build_service_diagnostics( + principal=principal, + transport="mcp", + authorized_projects=tuple(authorized_projects), + available_operations=frozenset(available_operations), + index_status=index_status, + projects=tuple(projects) if projects is not None else None, + include_shared=include_shared, + ) + except (PermissionError, ValueError) as error: + raise ValueError(str(error)) from error + return json.dumps(report.model_dump(mode="json"), ensure_ascii=False) + + @server.tool(name="get_node", structured_output=False) + def get_node( + project_id: str, + node_id: str, + ctx: Context, + revision: GitCommitId | None = None, + ) -> str: + """Read an accepted node revision and its exact evidence citations.""" + + principal = _principal(ctx, identity_provider) + try: + envelope = node_reader.get_node( + project_id, node_id, principal=principal, revision=revision + ) + except ReadError as error: + raise ValueError(str(error)) from error + return json.dumps(envelope.model_dump(mode="json"), ensure_ascii=False) + + @server.tool(name="node_history", structured_output=False) + def node_history(project_id: str, node_id: str, ctx: Context, limit: int = 20) -> str: + """Read bounded accepted revisions of one project-scoped node.""" + + principal = _principal(ctx, identity_provider) + try: + envelope = node_reader.get_history( + project_id, node_id, principal=principal, limit=limit + ) + except ReadError as error: + raise ValueError(str(error)) from error + return json.dumps(envelope.model_dump(mode="json"), ensure_ascii=False) + + if source_reader is not None: + available_operations.add("get_source") + + @server.tool(name="get_source", structured_output=False) + def get_source(project_id: str, revision_id: str, ctx: Context) -> str: + """Read a source revision only through one of its ingested_for grants.""" + + principal = _principal(ctx, identity_provider) + try: + envelope = source_reader.get_source(project_id, revision_id, principal=principal) + except ReadError as error: + raise ValueError(str(error)) from error + return json.dumps(envelope.model_dump(mode="json"), ensure_ascii=False) + + if search_service is not None: + available_operations.update({"list_projects", "search", "neighbors", "list_sources"}) + + @server.tool(name="list_projects", structured_output=False) + def list_projects(ctx: Context) -> str: + """List project IDs readable by the authenticated principal.""" + + principal = _principal(ctx, identity_provider) + return json.dumps(search_service.list_projects(principal=principal)) + + @server.tool(name="search", structured_output=False) + def search( + query: str, + projects: list[str], + include_shared: bool, + all_projects: bool, + limit: int, + ctx: Context, + budget_tokens: int, + cursor: str | None = None, + include_projects: list[str] | None = None, + node_types: list[NodeType] | None = None, + tags: list[str] | None = None, + audience: list[str] | None = None, + use_for: list[str] | None = None, + domain: list[str] | None = None, + confidences: list[Confidence] | None = None, + as_of_from: str | None = None, + as_of_to: str | None = None, + stale_only: bool = False, + ) -> str: + """Search accepted knowledge in an explicit, authorized project scope.""" + + principal = _principal(ctx, identity_provider) + try: + request = SearchRequest( + query=query, + scope=Scope( + projects=tuple(projects), + include_projects=( + tuple(include_projects) if include_projects is not None else None + ), + include_shared=include_shared, + all_projects=all_projects, + ), + limit=limit, + node_types=tuple(node_types or ()), + tags=tuple(tags or ()), + audience=tuple(audience or ()), + use_for=tuple(use_for or ()), + domain=tuple(domain or ()), + confidences=tuple(confidences or ()), + as_of_from=date.fromisoformat(as_of_from) if as_of_from else None, + as_of_to=date.fromisoformat(as_of_to) if as_of_to else None, + stale_only=stale_only, + cursor=cursor, + budget_tokens=budget_tokens, + ) + envelope = search_service.search(request, principal=principal) + except SearchError as error: + raise ValueError(str(error)) from error + except ValueError as error: + raise ValueError("Search request failed validation") from error + return json.dumps(envelope.model_dump(mode="json"), ensure_ascii=False) + + @server.tool(name="list_sources", structured_output=False) + def list_sources( + projects: list[str], + include_shared: bool, + all_projects: bool, + limit: int, + ctx: Context, + budget_tokens: int, + cursor: str | None = None, + include_projects: list[str] | None = None, + ) -> str: + """List source revisions granted to an explicit authorized project scope.""" + + principal = _principal(ctx, identity_provider) + try: + request = SourceListRequest( + scope=Scope( + projects=tuple(projects), + include_projects=( + tuple(include_projects) if include_projects is not None else None + ), + include_shared=include_shared, + all_projects=all_projects, + ), + limit=limit, + cursor=cursor, + budget_tokens=budget_tokens, + ) + envelope = search_service.list_sources(request, principal=principal) + except SearchError as error: + raise ValueError(str(error)) from error + except ValueError as error: + raise ValueError("Source list request failed validation") from error + return json.dumps(envelope.model_dump(mode="json"), ensure_ascii=False) + + @server.tool(name="neighbors", structured_output=False) + def neighbors( + node_id: str, + projects: list[str], + include_shared: bool, + all_projects: bool, + edge_types: list[str], + limit: int, + ctx: Context, + budget_tokens: int, + cursor: str | None = None, + include_projects: list[str] | None = None, + ) -> str: + """Read scoped typed relationships and their exact citations.""" + + principal = _principal(ctx, identity_provider) + try: + request = NeighborRequest.model_validate_json( + json.dumps( + { + "node_id": node_id, + "scope": { + "projects": projects, + "include_projects": include_projects, + "include_shared": include_shared, + "all_projects": all_projects, + }, + "edge_types": edge_types, + "limit": limit, + "cursor": cursor, + "budget_tokens": budget_tokens, + } + ), + strict=True, + ) + envelope = search_service.neighbors(request, principal=principal) + except SearchError as error: + raise ValueError(str(error)) from error + except ValueError as error: + raise ValueError("Neighbor request failed validation") from error + return json.dumps(envelope.model_dump(mode="json"), ensure_ascii=False) + + contribution_services = (extractor, acceptance_service, context_provider) + if any(service is not None for service in contribution_services) and ( + artifact_store is None or any(service is None for service in contribution_services) + ): + raise ValueError( + "Contribution tools require artifact, extraction, context and acceptance services" + ) + + if ( + artifact_store is not None + and extractor is not None + and acceptance_service is not None + and context_provider is not None + ): + available_operations.update( + { + "source_upload_begin", + "source_upload_status", + "source_upload_chunk", + "source_upload_finalize", + "source_capture_text", + "source_extract", + "source_extraction_block", + "proposal_evaluate", + "proposal_submit", + "proposal_status", + "proposal_withdraw", + "run_create", + "run_checkpoint", + "run_status", + } + ) + run_service = LocalRunService( + workflow_root=acceptance_service.root, + actor_is_current=acceptance_service.actor_is_current, + ) + + @server.tool(name="source_capture_text", structured_output=False) + def source_capture_text( + capture_id: str, + project_id: str, + source_key: str, + text: str, + ctx: Context, + title: str | None = None, + original_url: str | None = None, + media_type: Literal["text/plain", "text/markdown"] = "text/plain", + source_type: Literal[ + "paper", "review", "press", "vendor", "internal", "talk", "export", "other" + ] = "other", + reliability_grade: ReliabilityGrade = ReliabilityGrade.LOW, + license: str = "unspecified", + authors: list[str] | None = None, + publisher: str | None = None, + ) -> str: + """Archive explicitly supplied UTF-8 text and return its source candidate.""" + + principal = _principal(ctx, identity_provider) + _require_project_submission(principal, project_id) + if not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9._:-]{0,127}", capture_id): + raise ValueError("Capture ID has invalid syntax") + if not source_key.strip() or len(source_key) > 500: + raise ValueError("A stable source key between 1 and 500 characters is required") + if len(title or source_key) > 500 or len(license) > 500: + raise ValueError("Source title and license must be at most 500 characters") + if publisher is not None and len(publisher) > 300: + raise ValueError("Source publisher must be at most 300 characters") + if len(authors or ()) > 50 or any(len(author) > 300 for author in authors or ()): + raise ValueError("Source authors exceed the supported count or length") + content = text.encode("utf-8") + if not text.strip() or "\x00" in text or len(content) > MAX_PASTED_TEXT_BYTES: + raise ValueError("Pasted text must be non-empty and at most 1 MB of UTF-8") + safe_url = _redacted_source_url(original_url) if original_url else None + if original_url and not safe_url: + raise ValueError("Original URL is invalid or contains credentials") + digest = hashlib.sha256(content).hexdigest() + request_sha256 = hashlib.sha256( + json.dumps( + { + "project_id": project_id, + "source_key": source_key, + "content_sha256": digest, + "title": title, + "original_url": safe_url, + "media_type": media_type, + "source_type": source_type, + "reliability_grade": reliability_grade.value, + "license": license, + "authors": authors or [], + "publisher": publisher, + }, + ensure_ascii=False, + sort_keys=True, + separators=(",", ":"), + ).encode("utf-8") + ).hexdigest() + filename = "pasted-source.md" if media_type == "text/markdown" else "pasted-source.txt" + try: + with artifact_store.capture_receipt_guard(): + cached = artifact_store.get_capture_receipt( + capture_id, + owner_id=principal.principal_id, + project_id=project_id, + request_sha256=request_sha256, + ) + if cached is not None: + return json.dumps(cached, ensure_ascii=False) + session = artifact_store.begin( + filename=filename, + media_type=media_type, + byte_length=len(content), + expected_sha256=digest, + owner_id=principal.principal_id, + project_id=project_id, + ) + for offset in range(0, len(content), CHUNK_BYTES): + chunk = content[offset : offset + CHUNK_BYTES] + artifact_store.put_chunk( + session.upload_id, + offset=offset, + content=chunk, + chunk_sha256=hashlib.sha256(chunk).hexdigest(), + owner_id=principal.principal_id, + ) + receipt = artifact_store.finalize( + session.upload_id, owner_id=principal.principal_id + ) + captured_at = datetime.now(UTC) + source_candidate = _pasted_text_source_candidate( + source_key, + project_id=project_id, + captured_at=captured_at, + title=title, + original_url=safe_url, + artifact_sha256=receipt.sha256, + media_type=receipt.media_type, + byte_length=receipt.byte_length, + source_type=source_type, + reliability_grade=reliability_grade, + license=license, + authors=authors, + publisher=publisher, + ) + extraction = extractor.extract( + source_candidate.revision_id, content, media_type + ) + artifact_store.save_extraction( + extraction, owner_id=principal.principal_id, project_id=project_id + ) + result: dict[str, object] = { + "sha256": receipt.sha256, + "byte_length": receipt.byte_length, + "media_type": receipt.media_type, + "source_candidate": source_candidate.model_dump(mode="json"), + "extraction_id": extraction.extraction_id, + "capture_status": "archived", + } + artifact_store.save_capture_receipt( + capture_id, + owner_id=principal.principal_id, + project_id=project_id, + request_sha256=request_sha256, + result=result, + ) + except (UploadError, ValueError) as error: + raise ValueError(str(error)) from error + except portalocker.exceptions.LockException as error: + raise ValueError( + "Pasted-text capture is busy; retry with the same capture ID and inputs" + ) from error + return json.dumps( + result, + ensure_ascii=False, + ) + + @server.tool(name="source_capture_url", structured_output=False) + def source_capture_url( + project_id: str, + url: str, + ctx: Context, + title: str | None = None, + source_type: Literal[ + "paper", "review", "press", "vendor", "internal", "talk", "export", "other" + ] = "other", + reliability_grade: ReliabilityGrade = ReliabilityGrade.LOW, + license: str = "unspecified", + authors: list[str] | None = None, + publisher: str | None = None, + ) -> str: + """Stage an allowlisted URL and return a source-record proposal candidate.""" + + principal = _principal(ctx, identity_provider) + _require_project_submission(principal, project_id) + safe_url = _redacted_source_url(url) + if not safe_url: + raise ValueError("Source URL is invalid or contains credentials") + try: + capture = url_fetcher.fetch(url) + except UrlCaptureError as error: + if error.code == "FETCH_BLOCKED": + raise ValueError(str(error)) from error + candidate = _url_source_candidate( + safe_url, + project_id=project_id, + title=title, + source_type=source_type, + reliability_grade=reliability_grade, + license=license, + authors=authors, + publisher=publisher, + archive_status="capture-failed", + ) + return json.dumps( + { + "status": "capture_failed", + "error_code": error.code, + "message": str(error), + "source_candidate": candidate.model_dump(mode="json"), + }, + ensure_ascii=False, + ) + try: + extension = { + "application/pdf": ".pdf", + "text/html": ".html", + "text/markdown": ".md", + "text/plain": ".txt", + }.get(capture.media_type) + if extension is None: + raise UrlCaptureError( + "UNSUPPORTED_MEDIA_TYPE", "URL capture source format is unsupported" + ) + candidate_name = urlsplit(capture.final_url).path.rsplit("/", 1)[-1] + filename = ( + candidate_name + if candidate_name.lower().endswith(extension) + else f"captured-source{extension}" + ) + session = artifact_store.begin( + filename=filename, + media_type=capture.media_type, + byte_length=len(capture.content), + expected_sha256=hashlib.sha256(capture.content).hexdigest(), + owner_id=principal.principal_id, + project_id=project_id, + ) + for offset in range(0, len(capture.content), CHUNK_BYTES): + chunk = capture.content[offset : offset + CHUNK_BYTES] + artifact_store.put_chunk( + session.upload_id, + offset=offset, + content=chunk, + chunk_sha256=hashlib.sha256(chunk).hexdigest(), + owner_id=principal.principal_id, + ) + receipt = artifact_store.finalize( + session.upload_id, owner_id=principal.principal_id + ) + except (UrlCaptureError, UploadError) as error: + candidate = _url_source_candidate( + safe_url, + project_id=project_id, + title=title or capture.title, + source_type=source_type, + reliability_grade=reliability_grade, + license=license, + authors=authors, + publisher=publisher, + archive_status="capture-failed", + final_url=_redacted_source_url(capture.final_url), + ) + return json.dumps( + { + "status": "capture_failed", + "error_code": getattr(error, "code", "ARCHIVE_FAILED"), + "message": str(error), + "source_candidate": candidate.model_dump(mode="json"), + }, + ensure_ascii=False, + ) + source_candidate = _url_source_candidate( + safe_url, + project_id=project_id, + title=title or capture.title, + source_type=source_type, + reliability_grade=reliability_grade, + license=license, + authors=authors, + publisher=publisher, + archive_status="archived", + final_url=_redacted_source_url(capture.final_url), + artifact_sha256=receipt.sha256, + media_type=receipt.media_type, + byte_length=receipt.byte_length, + ) + return json.dumps( + { + "capture_status": "archived", + "original_url": _redacted_source_url(capture.original_url), + "final_url": _redacted_source_url(capture.final_url), + "title": capture.title, + "sha256": receipt.sha256, + "byte_length": receipt.byte_length, + "media_type": receipt.media_type, + "artifact_status": receipt.status, + "source_candidate": source_candidate.model_dump(mode="json"), + }, + ensure_ascii=False, + ) + + @server.tool(name="source_upload_begin", structured_output=False) + def source_upload_begin( + project_id: str, + filename: str, + media_type: str, + byte_length: int, + expected_sha256: str, + ctx: Context, + ) -> str: + """Begin a bounded source upload scoped to an authorized project.""" + + principal = _principal(ctx, identity_provider) + _require_project_submission(principal, project_id) + try: + session = artifact_store.begin( + filename=filename, + media_type=media_type, + byte_length=byte_length, + expected_sha256=expected_sha256, + owner_id=principal.principal_id, + project_id=project_id, + ) + except UploadError as error: + raise ValueError(str(error)) from error + return json.dumps( + { + "upload_id": session.upload_id, + "verified_offsets": session.verified_offsets, + "chunk_bytes": session.chunk_bytes, + "max_artifact_bytes": session.max_artifact_bytes, + "expires_at": session.expires_at, + } + ) + + @server.tool(name="source_upload_status", structured_output=False) + def source_upload_status(upload_id: str, ctx: Context) -> str: + """Resume an upload owned by the authenticated agent identity.""" + + principal = _principal(ctx, identity_provider) + try: + offsets = artifact_store.verified_offsets( + upload_id, owner_id=principal.principal_id + ) + except UploadError as error: + raise ValueError(str(error)) from error + return json.dumps({"upload_id": upload_id, "verified_offsets": offsets}) + + @server.tool(name="source_upload_chunk", structured_output=False) + def source_upload_chunk( + upload_id: str, + offset: int, + content_base64: str, + chunk_sha256: str, + ctx: Context, + ) -> str: + """Transfer one integrity-checked upload chunk.""" + + principal = _principal(ctx, identity_provider) + if len(content_base64) > ((CHUNK_BYTES + 2) // 3) * 4: + raise ValueError("Encoded upload chunk exceeds the supported limit") + try: + content = base64.b64decode(content_base64, validate=True) + offsets = artifact_store.put_chunk( + upload_id, + offset=offset, + content=content, + chunk_sha256=chunk_sha256, + owner_id=principal.principal_id, + ) + except (UploadError, binascii.Error) as error: + raise ValueError(str(error)) from error + return json.dumps({"upload_id": upload_id, "verified_offsets": offsets}) + + @server.tool(name="source_upload_finalize", structured_output=False) + def source_upload_finalize(upload_id: str, ctx: Context) -> str: + """Finalize and content-address a supported file or UTF-8 transcript artifact.""" + + principal = _principal(ctx, identity_provider) + try: + receipt = artifact_store.finalize(upload_id, owner_id=principal.principal_id) + except UploadError as error: + raise ValueError(str(error)) from error + return json.dumps( + { + "sha256": receipt.sha256, + "byte_length": receipt.byte_length, + "media_type": receipt.media_type, + "status": receipt.status, + } + ) + + @server.tool(name="source_extract", structured_output=False) + def source_extract( + project_id: str, + artifact_sha256: str, + source_revision_id: str, + ctx: Context, + ) -> str: + """Create and stage a versioned text extraction for a source revision.""" + + principal = _principal(ctx, identity_provider) + _require_project_submission(principal, project_id) + try: + with artifact_store.open_artifact( + artifact_sha256, + owner_id=principal.principal_id, + project_id=project_id, + ) as stream: + content = stream.read(50 * 1024 * 1024 + 1) + if len(content) > 50 * 1024 * 1024: + raise ValueError("Source exceeds the extraction limit") + media_type = artifact_store.artifact_media_type( + artifact_sha256, + owner_id=principal.principal_id, + project_id=project_id, + ) + record = extractor.extract(source_revision_id, content, media_type) + artifact_store.save_extraction( + record, owner_id=principal.principal_id, project_id=project_id + ) + except PdfExtractionError as error: + return json.dumps( + { + "source_revision_id": source_revision_id, + "status": "unavailable", + "error_code": error.code, + "warnings": [str(error)], + "blocks": [], + }, + ensure_ascii=False, + ) + except (UploadError, ValueError) as error: + raise ValueError(str(error)) from error + return json.dumps( + { + "extraction_id": record.extraction_id, + "source_revision_id": record.source_revision_id, + "parser_name": record.parser_name, + "parser_version": record.parser_version, + "status": record.status, + "warnings": record.warnings, + "blocks": [ + { + "block_id": block.block_id, + "ordinal": block.ordinal, + "codepoints": len(block.normalized_text), + "page": block.page, + } + for block in record.blocks + ], + } + ) + + @server.tool(name="source_extraction_block", structured_output=False) + def source_extraction_block( + project_id: str, + extraction_id: str, + block_id: str, + offset: int, + length: int, + ctx: Context, + ) -> str: + """Read a bounded normalized text slice and its source offset map.""" + + principal = _principal(ctx, identity_provider) + _require_project_submission(principal, project_id) + if offset < 0 or length < 1 or length > 12_000: + raise ValueError("Extraction slice is outside the supported limit") + extraction = artifact_store.get_extraction( + extraction_id, owner_id=principal.principal_id, project_id=project_id + ) + if extraction is None: + raise ValueError("Staged extraction does not exist") + block = next((item for item in extraction.blocks if item.block_id == block_id), None) + if block is None or offset >= len(block.normalized_text): + raise ValueError("Extraction block or offset does not exist") + end = min(offset + length, len(block.normalized_text)) + spans = block.normalization_map[offset:end] + original_start = min(start for start, _ in spans) + original_end = max(stop for _, stop in spans) + return json.dumps( + { + "extraction_id": extraction_id, + "block_id": block_id, + "page": block.page, + "offset": offset, + "normalized_text": block.normalized_text[offset:end], + "original_text": block.original_text[original_start:original_end], + "normalization_map": spans, + }, + ensure_ascii=False, + ) + + @server.tool(name="proposal_submit", structured_output=False) + def proposal_submit(proposal_json: str, ctx: Context) -> str: + """Validate and submit one bounded typed proposal revision.""" + + principal = _principal(ctx, identity_provider) + proposal = _parse_proposal(proposal_json) + context = context_provider() + _authorize_proposal(principal, proposal, context) + staged_extractions: dict[str, ExtractionRecord] = {} + for evidence in _proposal_evidence(proposal): + extraction = artifact_store.get_extraction( + evidence.extraction_id, + owner_id=principal.principal_id, + ) + if extraction is not None: + staged_extractions[extraction.extraction_id] = extraction + try: + outcome = acceptance_service.submit( + proposal, + actor=ActorContext( + principal.principal_id, + principal.credential_id, + roles=principal.roles, + project_ids=principal.project_ids, + readable_project_ids=_proposal_readable_projects( + principal, proposal, context + ), + ), + staged_extractions=staged_extractions, + ) + except AcceptanceError as error: + raise ValueError(str(error)) from error + result: dict[str, object] = { + "proposal_id": proposal.proposal_id, + "revision": proposal.revision, + "status": "accepted" if outcome.receipt else outcome.validation.status, + "validation_status": outcome.validation.status, + "eligible_for_auto_accept": outcome.validation.eligible_for_auto_accept, + "findings": [ + finding.model_dump(mode="json") for finding in outcome.validation.findings + ], + "affected_policy_ids": outcome.validation.affected_policy_ids, + "owner_challenge_ids": outcome.owner_challenge_ids, + "receipt": outcome.receipt.model_dump(mode="json") if outcome.receipt else None, + } + return json.dumps(result, ensure_ascii=False) + + @server.tool(name="proposal_evaluate", structured_output=False) + def proposal_evaluate(proposal_json: str, ctx: Context) -> str: + """Evaluate one bounded proposal without storing or publishing it.""" + + principal = _principal(ctx, identity_provider) + proposal = _parse_proposal(proposal_json) + context = context_provider() + _authorize_proposal(principal, proposal, context) + staged_extractions: dict[str, ExtractionRecord] = {} + for evidence in _proposal_evidence(proposal): + extraction = artifact_store.get_extraction( + evidence.extraction_id, + owner_id=principal.principal_id, + ) + if extraction is not None: + staged_extractions[extraction.extraction_id] = extraction + report = validate_proposal( + proposal, + projects=dict(context.projects), + policies=dict(context.policies), + sources=dict(context.sources), + extractions={**context.extractions, **staged_extractions}, + existing_nodes=dict(context.existing_nodes), + current_base_commit=acceptance_service.repository.head(), + readable_project_ids=_proposal_readable_projects(principal, proposal, context), + ) + return json.dumps( + { + "proposal_id": proposal.proposal_id, + "revision": proposal.revision, + "status": report.status, + "eligible_for_auto_accept": report.eligible_for_auto_accept, + "findings": [finding.model_dump(mode="json") for finding in report.findings], + "affected_policy_ids": report.affected_policy_ids, + }, + ensure_ascii=False, + ) + + @server.tool(name="proposal_status", structured_output=False) + def proposal_status(proposal_id: str, revision: int, ctx: Context) -> str: + """Read bounded lifecycle for a proposal created by this authenticated agent.""" + + principal = _principal(ctx, identity_provider) + if "agent" not in principal.roles: + raise PermissionError("Only the proposing agent can read proposal status") + try: + status = acceptance_service.proposal_status( + proposal_id, + revision, + actor=ActorContext(principal.principal_id, principal.credential_id), + ) + except AcceptanceError as error: + raise ValueError(str(error)) from error + return json.dumps(status.model_dump(mode="json"), ensure_ascii=False) + + @server.tool(name="proposal_withdraw", structured_output=False) + def proposal_withdraw(proposal_id: str, revision: int, ctx: Context) -> str: + """Withdraw this agent's unpublished proposal under the canonical writer lock.""" + + principal = _principal(ctx, identity_provider) + if "agent" not in principal.roles: + raise PermissionError("Only a proposing agent can withdraw a proposal") + try: + status = acceptance_service.withdraw_proposal( + proposal_id, + revision, + actor=ActorContext(principal.principal_id, principal.credential_id), + ) + except AcceptanceError as error: + raise ValueError(str(error)) from error + return json.dumps(status.model_dump(mode="json"), ensure_ascii=False) + + @server.tool(name="run_create", structured_output=False) + def run_create(request_json: str, ctx: Context) -> str: + """Create or idempotently recover an agent-owned durable run.""" + + principal = _principal(ctx, identity_provider) + if "agent" not in principal.roles or principal.auth_method != "bearer": + raise PermissionError("Durable runs require an agent bearer credential") + request = _parse_run_request(request_json, RunCreateRequest) + _require_run_scope(principal, request.scope) + try: + record = run_service.create( + request, + actor=ActorContext(principal.principal_id, principal.credential_id), + ) + except RunError as error: + raise ValueError(f"{error.code}: {error}") from error + return json.dumps(record.model_dump(mode="json"), ensure_ascii=False) + + @server.tool(name="run_checkpoint", structured_output=False) + def run_checkpoint(request_json: str, ctx: Context) -> str: + """Append an idempotent, revision-checked checkpoint to an owned run.""" + + principal = _principal(ctx, identity_provider) + if "agent" not in principal.roles or principal.auth_method != "bearer": + raise PermissionError("Durable runs require an agent bearer credential") + request = _parse_run_request(request_json, RunCheckpointRequest) + actor = ActorContext(principal.principal_id, principal.credential_id) + try: + current = run_service.status(request.run_id, actor=actor) + _require_run_scope(principal, current.scope) + record = run_service.checkpoint(request, actor=actor) + except RunError as error: + raise ValueError(f"{error.code}: {error}") from error + return json.dumps(record.model_dump(mode="json"), ensure_ascii=False) + + @server.tool(name="run_status", structured_output=False) + def run_status(run_id: str, ctx: Context) -> str: + """Read the latest durable checkpoint for the authenticated run owner.""" + + principal = _principal(ctx, identity_provider) + if "agent" not in principal.roles or principal.auth_method != "bearer": + raise PermissionError("Durable runs require an agent bearer credential") + try: + record = run_service.status( + run_id, + actor=ActorContext(principal.principal_id, principal.credential_id), + ) + _require_run_scope(principal, record.scope) + except RunError as error: + raise ValueError(f"{error.code}: {error}") from error + return json.dumps(record.model_dump(mode="json"), ensure_ascii=False) + + if context_provider is not None: + available_operations.add("get_project_context") + + @server.tool(name="get_project_context", structured_output=False) + def get_project_context(project_id: str, ctx: Context) -> str: + """Read the authorized project's research lens without exposing its policy.""" + + principal = _principal(ctx, identity_provider) + if not principal.can_read_project(project_id): + raise PermissionError("Project context is unavailable") + context = context_provider() + project = context.projects.get(project_id) + if project is None: + raise ValueError("Project context is unavailable") + response = ProjectContextResponse( + lens=ProjectLens( + project_id=project.project_id, + title=project.title, + status=project.status, + north_star=project.north_star, + key_questions=project.key_questions, + watch_list=project.watch_list, + taxonomy_extensions=project.taxonomy_extensions, + ), + ) + encoded = json.dumps(response.model_dump(mode="json"), ensure_ascii=False) + if len(encoded.encode("utf-8")) > MAX_PROJECT_CONTEXT_BYTES: + raise ValueError("Project context exceeds the supported response limit") + return encoded + + return server + + +def _require_project_submission(principal: Principal, project_id: str) -> None: + if not principal.can_submit_project(project_id): + raise PermissionError("Agent credential cannot submit to this project") + + +def _redacted_source_url(url: str) -> str: + """Keep a credential-free HTTP locator without query tokens or fragments.""" + + if len(url) > 4_000 or any(ord(char) < 0x20 or ord(char) == 0x7F for char in url): + return "" + try: + parsed = urlsplit(url) + hostname = parsed.hostname + port = parsed.port + except ValueError: + return "" + scheme = parsed.scheme.lower() + if ( + scheme not in {"http", "https"} + or not hostname + or parsed.username is not None + or parsed.password is not None + or port not in {None, 80 if scheme == "http" else 443} + ): + return "" + host = hostname.lower() + if ":" in host: + host = f"[{host}]" + netloc = host + return urlunsplit((scheme, netloc, parsed.path or "/", "", "")) + + +def _url_source_candidate( + safe_url: str, + *, + project_id: str, + title: str | None, + source_type: Literal[ + "paper", "review", "press", "vendor", "internal", "talk", "export", "other" + ], + reliability_grade: ReliabilityGrade, + license: str, + authors: list[str] | None, + publisher: str | None, + archive_status: Literal["archived", "capture-failed"], + final_url: str | None = None, + artifact_sha256: str | None = None, + media_type: str | None = None, + byte_length: int = 0, +) -> SourceRevisionRecord: + captured_at = datetime.now(UTC) + source_id = f"url:{hashlib.sha256(safe_url.encode('utf-8')).hexdigest()[:32]}" + if archive_status == "archived": + if artifact_sha256 is None: + raise ValueError("Archived URL candidate requires an artifact digest") + capture_stamp = captured_at.strftime("%Y%m%dT%H%M%S%fZ") + revision_id = f"{source_id}:r:{artifact_sha256}:{capture_stamp}" + else: + attempt = captured_at.strftime("%Y%m%dT%H%M%S%fZ") + revision_id = f"{source_id}:failed:{attempt}" + host = urlsplit(safe_url).hostname + return SourceRevisionRecord( + source_id=source_id, + revision_id=revision_id, + artifact_sha256=artifact_sha256, + media_type=media_type or "application/octet-stream", + byte_length=byte_length, + captured_at=captured_at, + capture_method="url-fetch", + original_url=safe_url, + final_url=final_url, + title=(title or host or "Web source")[:500], + authors=tuple(authors or ()), + publisher=publisher, + license=license, + source_type=source_type, + reliability_grade=reliability_grade, + ingested_for=(project_id,), + archive_status=archive_status, + ) + + +def _pasted_text_source_candidate( + source_key: str, + *, + project_id: str, + captured_at: datetime, + title: str | None, + original_url: str | None, + artifact_sha256: str, + media_type: str, + byte_length: int, + source_type: Literal[ + "paper", "review", "press", "vendor", "internal", "talk", "export", "other" + ], + reliability_grade: ReliabilityGrade, + license: str, + authors: list[str] | None, + publisher: str | None, +) -> SourceRevisionRecord: + identity_digest = hashlib.sha256(source_key.encode("utf-8")).hexdigest()[:30] + source_id = f"pasted:{identity_digest}" + captured_stamp = captured_at.strftime("%Y%m%dT%H%M%S%fZ") + revision_id = f"{source_id}:r:{artifact_sha256}:{captured_stamp}" + return SourceRevisionRecord( + source_id=source_id, + revision_id=revision_id, + artifact_sha256=artifact_sha256, + media_type=media_type, + byte_length=byte_length, + captured_at=captured_at, + capture_method="pasted-text", + original_url=original_url, + title=(title or source_key)[:500], + authors=tuple(authors or ()), + publisher=publisher, + license=license, + source_type=source_type, + reliability_grade=reliability_grade, + ingested_for=(project_id,), + archive_status="archived", + ) + + +def _reject_duplicate_keys(pairs: list[tuple[str, object]]) -> dict[str, object]: + result: dict[str, object] = {} + for key, value in pairs: + if key in result: + raise ValueError("duplicate JSON key") + result[key] = value + return result + + +def _parse_proposal(proposal_json: str) -> ProposalRevision: + if len(proposal_json.encode("utf-8")) > 1_000_000: + raise ValueError("Proposal exceeds the supported request size") + try: + parsed = json.loads(proposal_json, object_pairs_hook=_reject_duplicate_keys) + proposal = ProposalRevision.model_validate_json(proposal_json, strict=True) + except (ValueError, ValidationError) as error: + raise ValueError("Proposal does not match the GEWU v1 contract") from error + if not isinstance(parsed, dict): + raise ValueError("Proposal must be a JSON object") + return proposal + + +def _parse_run_request[RequestT: (RunCreateRequest, RunCheckpointRequest)]( + request_json: str, request_type: type[RequestT] +) -> RequestT: + if len(request_json.encode("utf-8")) > 1_000_000: + raise ValueError("Run request exceeds the supported size") + try: + payload = json.loads(request_json, object_pairs_hook=_reject_duplicate_keys) + request = request_type.model_validate_json(request_json, strict=True) + except (ValueError, ValidationError) as error: + raise ValueError("Run request does not match the GEWU v1 contract") from error + if not isinstance(payload, dict): + raise ValueError("Run request must be a JSON object") + return request + + +def _require_run_scope(principal: Principal, scope: Scope) -> None: + if scope.all_projects: + raise PermissionError("Durable runs require explicit project scope") + affected = set(scope.projects) + if scope.include_shared: + affected.add("shared") + if not affected or any(not principal.can_submit_project(project_id) for project_id in affected): + raise PermissionError("Agent credential lacks a grant for every run project") + + +def _proposal_evidence(proposal: ProposalRevision) -> Iterator[EvidenceReference]: + for operation in proposal.operations: + if operation.node is not None: + for claim in operation.node.evidence_claims: + yield from claim.evidence + if operation.edge is not None: + yield from operation.edge.evidence + + +def _proposal_readable_projects( + principal: Principal, proposal: ProposalRevision, context: ValidationContext +) -> frozenset[str]: + """Limit duplicate review to caller-readable projects explicitly in proposal scope.""" + + requested = ( + set(context.projects) if proposal.scope.all_projects else set(proposal.scope.projects) + ) + if proposal.scope.include_shared: + requested.add("shared") + return frozenset( + project_id for project_id in requested if principal.can_read_project(project_id) + ) + + +def _authorize_proposal( + principal: Principal, proposal: ProposalRevision, context: ValidationContext +) -> None: + if "agent" not in principal.roles or proposal.proposer_id != principal.principal_id: + raise PermissionError("An attributed agent credential must submit this proposal") + proposed_nodes = { + operation.node.node_id: operation.node + for operation in proposal.operations + if operation.node is not None + } + known_nodes = {**context.existing_nodes, **proposed_nodes} + affected: set[str] = set() + for operation in proposal.operations: + if operation.source is not None: + affected.update(("shared", *operation.source.ingested_for)) + if operation.node is not None: + affected.add(operation.node.project_id) + if operation.edge is not None: + for endpoint in (operation.edge.source, operation.edge.target): + if endpoint.kind == "project": + affected.add(endpoint.id) + elif endpoint.kind == "source": + affected.add("shared") + elif endpoint.id in known_nodes: + affected.add(known_nodes[endpoint.id].project_id) + if operation.target_id is not None and operation.target_id in known_nodes: + affected.add(known_nodes[operation.target_id].project_id) + if not affected or any(not principal.can_submit_project(project_id) for project_id in affected): + raise PermissionError("Agent credential lacks a grant for every affected project") diff --git a/src/gewu/transports/owner_mcp_server.py b/src/gewu/transports/owner_mcp_server.py new file mode 100644 index 0000000..170329e --- /dev/null +++ b/src/gewu/transports/owner_mcp_server.py @@ -0,0 +1,116 @@ +"""Owner-only MCP surface for exact, challenge-bound protected decisions.""" + +from __future__ import annotations + +import json +from typing import Protocol + +from mcp.server import MCPServer +from mcp.server.mcpserver import Context + +from gewu.application.identity import OwnerIdentityProvider, Principal + + +class OwnerReviewProvider(Protocol): + def get_owner_challenge( + self, challenge_id: str, *, principal_id: str + ) -> dict[str, object] | None: ... + + def decide_owner_challenge( + self, + *, + challenge_id: str, + principal_id: str, + proposal_revision: int, + payload_digest: str, + diff_digest: str, + decision: str, + rationale: str, + owner_session_id: str, + audience: str, + ) -> dict[str, object]: ... + + +def _owner_principal( + ctx: Context, + identities: OwnerIdentityProvider, + *, + challenge_id: str, + audience: str, +) -> Principal: + headers = ctx.headers or {} + authorization = next( + (value for key, value in headers.items() if key.lower() == "authorization"), "" + ) + scheme, _, token = authorization.partition(" ") + principal = ( + identities.authenticate_owner_session(token, challenge_id=challenge_id, audience=audience) + if scheme.lower() == "bearer" and token + else None + ) + if principal is None: + raise PermissionError("A current owner session bound to this challenge is required") + return principal + + +def create_owner_mcp_server( + *, + identities: OwnerIdentityProvider, + reviews: OwnerReviewProvider, + audience: str, +) -> MCPServer: + server = MCPServer( + "GEWU Owner Review", + instructions="Review the exact proposal diff bound to your short-lived owner session.", + ) + + @server.tool(name="owner_challenge_get", structured_output=False) + def owner_challenge_get(challenge_id: str, ctx: Context) -> str: + """Read challenge metadata bound to this owner session.""" + principal = _owner_principal(ctx, identities, challenge_id=challenge_id, audience=audience) + record = reviews.get_owner_challenge(challenge_id, principal_id=principal.principal_id) + if record is None: + raise ValueError("Owner challenge is unavailable") + record.pop("diff_json", None) + record.pop("owner_session_id", None) + return json.dumps(record, ensure_ascii=False) + + @server.tool(name="owner_diff_get", structured_output=False) + def owner_diff_get(challenge_id: str, ctx: Context) -> str: + """Read the complete, digest-bound diff; oversized diffs fail rather than truncate.""" + principal = _owner_principal(ctx, identities, challenge_id=challenge_id, audience=audience) + record = reviews.get_owner_challenge(challenge_id, principal_id=principal.principal_id) + if record is None: + raise ValueError("Owner challenge is unavailable") + raw_diff = record.pop("diff_json") + if not isinstance(raw_diff, str) or len(raw_diff.encode("utf-8")) > 512_000: + raise ValueError("Owner challenge diff is unavailable") + record["diff"] = json.loads(raw_diff) + return json.dumps(record, ensure_ascii=False) + + @server.tool(name="owner_decide", structured_output=False) + def owner_decide( + challenge_id: str, + expected_proposal_revision: int, + expected_payload_digest: str, + expected_diff_digest: str, + decision: str, + rationale: str, + ctx: Context, + ) -> str: + """Authorize or reject one exact proposal revision and diff.""" + principal = _owner_principal(ctx, identities, challenge_id=challenge_id, audience=audience) + result = reviews.decide_owner_challenge( + challenge_id=challenge_id, + principal_id=principal.principal_id, + proposal_revision=expected_proposal_revision, + payload_digest=expected_payload_digest, + diff_digest=expected_diff_digest, + decision=decision, + rationale=rationale, + owner_session_id=principal.credential_id.rpartition(":")[2], + audience=audience, + ) + return json.dumps(result, ensure_ascii=False) + + return server diff --git a/src/gewu/transports/stdio_connector.py b/src/gewu/transports/stdio_connector.py new file mode 100644 index 0000000..3fcb7dd --- /dev/null +++ b/src/gewu/transports/stdio_connector.py @@ -0,0 +1,625 @@ +"""Local stdio bridge for explicitly selected files and the remote GEWU MCP service.""" + +from __future__ import annotations + +import argparse +import base64 +import hashlib +import json +import os +import re +import secrets +import stat +import threading +from collections.abc import AsyncIterator, Callable +from contextlib import AbstractAsyncContextManager, asynccontextmanager +from dataclasses import dataclass +from datetime import UTC, datetime +from pathlib import Path +from typing import Literal +from urllib.parse import urlparse + +import httpx2 +from mcp import Client +from mcp.client.streamable_http import streamable_http_client +from mcp.server import MCPServer +from mcp.server.mcpserver import Context +from mcp.types import TextContent + +from gewu.application.paths import reject_symlink_components +from gewu.application.uploads import CHUNK_BYTES, MAX_ARTIFACT_BYTES +from gewu.domain.contracts import ReliabilityGrade, SourceRevisionRecord + +MAX_CONNECTOR_FILES = 128 +MAX_CONNECTOR_STATE_ENTRIES = 1024 +_FILE_ID = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$") + + +@dataclass(frozen=True, slots=True) +class SelectedFile: + file_id: str + path: Path + + +RemoteClientFactory = Callable[[], AbstractAsyncContextManager[Client]] + + +class UploadStateStore: + """Private, restart-safe connector checkpoints keyed by source and project.""" + + def __init__(self, path: Path) -> None: + self.path = path.expanduser().absolute() + reject_symlink_components(self.path) + self.path.parent.mkdir(parents=True, exist_ok=True, mode=0o700) + self.path.parent.chmod(0o700) + self._lock = threading.RLock() + + def _read(self) -> dict[str, dict[str, str]]: + if not self.path.exists(): + return {} + if self.path.is_symlink() or not self.path.is_file(): + raise ValueError("Connector state path must be a private regular file") + if stat.S_IMODE(self.path.stat().st_mode) & 0o077: + raise ValueError("Connector state file permissions must be private") + try: + value = json.loads(self.path.read_text(encoding="utf-8")) + except (OSError, UnicodeError, json.JSONDecodeError) as error: + raise ValueError("Connector state file is invalid") from error + if not isinstance(value, dict) or any( + not isinstance(key, str) + or not isinstance(item, dict) + or any(not isinstance(k, str) or not isinstance(v, str) for k, v in item.items()) + for key, item in value.items() + ): + raise ValueError("Connector state file is invalid") + return value + + def get(self, key: str) -> dict[str, str] | None: + with self._lock: + return self._read().get(key) + + def set(self, key: str, **values: str) -> None: + with self._lock: + state = self._read() + state[key] = { + **state.get(key, {}), + **values, + "updated_at": datetime.now(UTC).isoformat(timespec="microseconds"), + } + self._prune(state, keep=key) + self._write(state) + + def set_completed(self, key: str, **values: str) -> None: + with self._lock: + state = self._read() + state[key] = { + **values, + "updated_at": datetime.now(UTC).isoformat(timespec="microseconds"), + } + self._prune(state, keep=key) + self._write(state) + + def set_ready(self, key: str, **values: str) -> None: + """Retain source identity after upload while allowing extraction retries.""" + + with self._lock: + state = self._read() + state[key] = { + **values, + "updated_at": datetime.now(UTC).isoformat(timespec="microseconds"), + } + self._prune(state, keep=key) + self._write(state) + + def remove(self, key: str) -> None: + with self._lock: + state = self._read() + state.pop(key, None) + self._write(state) + + def _write(self, state: dict[str, dict[str, str]]) -> None: + temporary = self.path.with_name(f".{self.path.name}.{secrets.token_hex(8)}.tmp") + try: + with temporary.open("x", encoding="utf-8") as stream: + json.dump(state, stream, ensure_ascii=False, sort_keys=True, separators=(",", ":")) + stream.flush() + os.fsync(stream.fileno()) + os.chmod(temporary, 0o600) + os.replace(temporary, self.path) + finally: + temporary.unlink(missing_ok=True) + + @staticmethod + def _prune(state: dict[str, dict[str, str]], *, keep: str) -> None: + while len(state) > MAX_CONNECTOR_STATE_ENTRIES: + candidates = (key for key in state if key != keep) + oldest = min(candidates, key=lambda key: state[key].get("updated_at", "")) + del state[oldest] + + +async def _active_upload( + remote: Client, + *, + selected: SelectedFile, + project_id: str, + source_revision_id: str, + media_type: str, + byte_length: int, + digest: str, + prior: dict[str, str] | None, + state_store: UploadStateStore | None, + state_key: str, +) -> tuple[str | None, set[int], dict[str, object] | None]: + if prior is not None and isinstance(prior.get("upload_id"), str): + try: + status = _json_tool_result( + await remote.call_tool("source_upload_status", {"upload_id": prior["upload_id"]}) + ) + offsets = status.get("verified_offsets") + if ( + isinstance(offsets, list) + and all( + isinstance(offset, int) and not isinstance(offset, bool) for offset in offsets + ) + and offsets == list(range(0, len(offsets) * CHUNK_BYTES, CHUNK_BYTES)) + and all( + 0 <= offset < byte_length and offset % CHUNK_BYTES == 0 for offset in offsets + ) + ): + return prior["upload_id"], set(offsets), None + raise ValueError("GEWU upload status could not be verified") + except ValueError: + try: + recovered = _json_tool_result( + await remote.call_tool( + "source_extract", + { + "project_id": project_id, + "artifact_sha256": digest, + "source_revision_id": source_revision_id, + }, + ) + ) + except ValueError: + pass + else: + return None, set(), recovered + + begun = _json_tool_result( + await remote.call_tool( + "source_upload_begin", + { + "project_id": project_id, + "filename": selected.path.name, + "media_type": media_type, + "byte_length": byte_length, + "expected_sha256": digest, + }, + ) + ) + upload_id = begun.get("upload_id") + if not isinstance(upload_id, str): + raise ValueError("GEWU did not return an upload session") + if state_store is not None: + state_store.set(state_key, upload_id=upload_id) + return upload_id, set(), None + + +def _read_selected_file(selected: SelectedFile) -> tuple[bytes, str]: + path = selected.path.expanduser().absolute() + try: + reject_symlink_components(path) + except ValueError as error: + raise ValueError("Selected file path cannot traverse symbolic links") from error + try: + descriptor = os.open(path, os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0)) + except OSError as error: + raise ValueError("Selected file is unavailable") from error + try: + metadata = os.fstat(descriptor) + if not stat.S_ISREG(metadata.st_mode) or metadata.st_size < 1: + raise ValueError("Selected file must be a non-empty regular file") + if metadata.st_size > MAX_ARTIFACT_BYTES: + raise ValueError("Selected file exceeds the 50 MiB limit") + content = bytearray() + while len(content) <= MAX_ARTIFACT_BYTES: + block = os.read(descriptor, min(1024 * 1024, MAX_ARTIFACT_BYTES + 1 - len(content))) + if not block: + break + content.extend(block) + if len(content) > MAX_ARTIFACT_BYTES or len(content) != metadata.st_size: + raise ValueError("Selected file changed or exceeded the supported limit") + finally: + os.close(descriptor) + return bytes(content), hashlib.sha256(content).hexdigest() + + +async def upload_selected_file( + remote: Client, + selected: SelectedFile, + *, + project_id: str, + source_revision_id: str | None = None, + state_store: UploadStateStore | None = None, + title: str | None = None, + source_type: Literal[ + "paper", "review", "press", "vendor", "internal", "talk", "export", "other" + ] = "other", + reliability_grade: ReliabilityGrade = ReliabilityGrade.LOW, + license: str = "unspecified", + authors: list[str] | None = None, + publisher: str | None = None, + capture_method: Literal["local-file", "notebooklm-export", "transcript"] | None = None, +) -> dict[str, object]: + """Transfer selected bytes inside the connector and return metadata only.""" + + media_type = _selected_media_type(selected.path) + effective_capture_method = capture_method or ( + "transcript" if selected.path.suffix.lower() in {".vtt", ".srt"} else "local-file" + ) + if effective_capture_method == "transcript" and selected.path.suffix.lower() not in { + ".vtt", + ".srt", + }: + raise ValueError("Transcript capture requires a WebVTT or SubRip file") + if effective_capture_method == "notebooklm-export" and selected.path.suffix.lower() not in { + ".txt", + ".md", + ".markdown", + ".pdf", + }: + raise ValueError( + "NotebookLM export provenance is supported for text, Markdown, and PDF files" + ) + content, digest = _read_selected_file(selected) + source_id = _local_source_id(selected.file_id) + state_key = hashlib.sha256( + json.dumps( + [project_id, source_id, digest, source_revision_id or "auto", effective_capture_method], + separators=(",", ":"), + ).encode() + ).hexdigest() + state = state_store.get(state_key) if state_store is not None else None + if state is not None and "completed_receipt" in state: + try: + completed = json.loads(state["completed_receipt"]) + except json.JSONDecodeError as error: + raise ValueError("Completed connector receipt is invalid") from error + candidate = completed.get("source_candidate") if isinstance(completed, dict) else None + if ( + not isinstance(completed, dict) + or completed.get("sha256") != digest + or not isinstance(candidate, dict) + or candidate.get("source_id") != source_id + or candidate.get("capture_method") != effective_capture_method + or ( + source_revision_id is not None + and candidate.get("revision_id") != source_revision_id + ) + ): + raise ValueError("Completed connector receipt does not match this source revision") + return completed + captured_at_text = state.get("captured_at") if state is not None else None + if captured_at_text is None: + captured_at_text = datetime.now(UTC).isoformat(timespec="microseconds") + if state_store is not None: + state_store.set(state_key, captured_at=captured_at_text) + state = {**(state or {}), "captured_at": captured_at_text} + try: + captured_at = datetime.fromisoformat(captured_at_text) + except ValueError as error: + raise ValueError("Connector capture timestamp is invalid") from error + utc_offset = captured_at.utcoffset() + if utc_offset is None or utc_offset.total_seconds() != 0: + raise ValueError("Connector capture timestamp must be UTC") + effective_revision_id = source_revision_id or _local_source_revision_id( + source_id, digest, captured_at + ) + extraction: dict[str, object] | None = None + if state is None or state.get("artifact_sha256") != digest: + upload_id, offsets, extraction = await _active_upload( + remote, + selected=selected, + project_id=project_id, + source_revision_id=effective_revision_id, + media_type=media_type, + byte_length=len(content), + digest=digest, + prior=state, + state_store=state_store, + state_key=state_key, + ) + if extraction is None: + assert upload_id is not None + for offset in range(0, len(content), CHUNK_BYTES): + if offset in offsets: + continue + chunk = content[offset : offset + CHUNK_BYTES] + result = await remote.call_tool( + "source_upload_chunk", + { + "upload_id": upload_id, + "offset": offset, + "content_base64": base64.b64encode(chunk).decode("ascii"), + "chunk_sha256": hashlib.sha256(chunk).hexdigest(), + }, + ) + _json_tool_result(result) + finalized = _json_tool_result( + await remote.call_tool("source_upload_finalize", {"upload_id": upload_id}) + ) + if finalized.get("sha256") != digest: + raise ValueError("GEWU artifact digest differs from the selected file") + if state_store is not None: + state_store.set(state_key, artifact_sha256=digest) + if extraction is None: + extraction = _json_tool_result( + await remote.call_tool( + "source_extract", + { + "project_id": project_id, + "artifact_sha256": digest, + "source_revision_id": effective_revision_id, + }, + ) + ) + receipt = _connector_receipt( + selected, + content, + digest, + media_type, + extraction, + source_revision_id=effective_revision_id, + project_id=project_id, + title=title, + source_type=source_type, + reliability_grade=reliability_grade, + license=license, + authors=authors, + publisher=publisher, + captured_at=captured_at, + capture_method=effective_capture_method, + ) + if state_store is not None: + if receipt["extraction_status"] in {"complete", "partial"}: + state_store.set_completed( + state_key, + artifact_sha256=digest, + captured_at=captured_at_text, + completed_receipt=json.dumps(receipt, ensure_ascii=False, sort_keys=True), + ) + else: + state_store.set_ready( + state_key, + artifact_sha256=digest, + captured_at=captured_at_text, + ) + return receipt + + +def _connector_receipt( + selected: SelectedFile, + content: bytes, + digest: str, + media_type: str, + extraction: dict[str, object], + *, + source_revision_id: str, + project_id: str, + title: str | None, + source_type: Literal[ + "paper", "review", "press", "vendor", "internal", "talk", "export", "other" + ], + reliability_grade: ReliabilityGrade, + license: str, + authors: list[str] | None, + publisher: str | None, + captured_at: datetime, + capture_method: Literal["local-file", "notebooklm-export", "transcript"], +) -> dict[str, object]: + source_id = _local_source_id(selected.file_id) + source = SourceRevisionRecord( + source_id=source_id, + revision_id=source_revision_id, + artifact_sha256=digest, + media_type=media_type, + byte_length=len(content), + captured_at=captured_at, + capture_method=capture_method, + title=(title or selected.path.name)[:500], + authors=tuple(authors or ()), + publisher=publisher, + license=license, + source_type=source_type, + reliability_grade=reliability_grade, + ingested_for=(project_id,), + archive_status="archived", + ) + return { + "file_id": selected.file_id, + "byte_length": len(content), + "sha256": digest, + "media_type": media_type, + "source_candidate": source.model_dump(mode="json"), + "extraction_status": extraction.get("status", "complete"), + "extraction_error_code": extraction.get("error_code"), + "extraction_id": extraction.get("extraction_id"), + "blocks": extraction.get("blocks"), + } + + +def _selected_media_type(path: Path) -> str: + suffix = path.suffix.lower() + if suffix == ".pdf": + return "application/pdf" + if suffix == ".vtt": + return "text/vtt" + if suffix == ".srt": + return "application/x-subrip" + if suffix in {".md", ".markdown"}: + return "text/markdown" + if suffix in {".htm", ".html"}: + return "text/html" + if suffix == ".txt": + return "text/plain" + raise ValueError("Selected file type is not supported for source intake") + + +def _local_source_id(file_id: str) -> str: + return f"local:{hashlib.sha256(file_id.encode('utf-8')).hexdigest()[:32]}" + + +def _local_source_revision_id(source_id: str, digest: str, captured_at: datetime) -> str: + stamp = captured_at.strftime("%Y%m%dT%H%M%S%fZ") + return f"{source_id}:r:{digest}:{stamp}" + + +def _json_tool_result(result: object) -> dict[str, object]: + if getattr(result, "is_error", True): + raise ValueError("GEWU source operation failed") + content = getattr(result, "content", ()) + if not content or not isinstance(content[0], TextContent): + raise ValueError("GEWU returned an invalid source-operation response") + try: + value = json.loads(content[0].text) + except (TypeError, json.JSONDecodeError) as error: + raise ValueError("GEWU returned an invalid source-operation response") from error + if not isinstance(value, dict): + raise ValueError("GEWU returned an invalid source-operation response") + return value + + +def create_connector_server( + selected_files: tuple[SelectedFile, ...], + remote_client_factory: RemoteClientFactory, + state_store: UploadStateStore | None = None, +) -> MCPServer: + if not selected_files or len(selected_files) > MAX_CONNECTOR_FILES: + raise ValueError("Configure between 1 and 128 explicitly selected files") + if any(not _FILE_ID.fullmatch(item.file_id) for item in selected_files): + raise ValueError("Selected file IDs have invalid syntax") + if len({item.file_id for item in selected_files}) != len(selected_files): + raise ValueError("Selected file IDs must be unique") + server = MCPServer("GEWU selected-file connector", version="0.1.0") + files = {item.file_id: item for item in selected_files} + + @server.tool(name="list_selected_files", structured_output=False) + def list_selected_files() -> str: + """List only files explicitly allowlisted when this connector was started.""" + + listed = [] + for selected in files.values(): + content, digest = _read_selected_file(selected) + listed.append( + { + "file_id": selected.file_id, + "filename": selected.path.name, + "byte_length": len(content), + "sha256": digest, + } + ) + return json.dumps(listed) + + @server.tool(name="upload_selected_file", structured_output=False) + async def upload_selected_file_tool( + file_id: str, + project_id: str, + ctx: Context, + source_revision_id: str | None = None, + title: str | None = None, + source_type: Literal[ + "paper", "review", "press", "vendor", "internal", "talk", "export", "other" + ] = "other", + reliability_grade: ReliabilityGrade = ReliabilityGrade.LOW, + license: str = "unspecified", + authors: list[str] | None = None, + publisher: str | None = None, + capture_method: Literal["local-file", "notebooklm-export", "transcript"] | None = None, + ) -> str: + """Upload an allowlisted local file; file bytes stay inside this connector.""" + + selected = files.get(file_id) + if selected is None: + raise ValueError("File ID is not in the connector's explicit allowlist") + try: + async with remote_client_factory() as remote: + receipt = await upload_selected_file( + remote, + selected, + project_id=project_id, + source_revision_id=source_revision_id, + state_store=state_store, + title=title, + source_type=source_type, + reliability_grade=reliability_grade, + license=license, + authors=authors, + publisher=publisher, + capture_method=capture_method, + ) + except Exception: + raise ValueError( + "GEWU source transfer failed; retry the selected-file operation" + ) from None + return json.dumps(receipt, ensure_ascii=False) + + return server + + +def _parse_selected_file(value: str) -> SelectedFile: + file_id, separator, raw_path = value.partition("=") + if not separator or not _FILE_ID.fullmatch(file_id) or not raw_path: + raise argparse.ArgumentTypeError("--file must use ID=PATH with a stable file ID") + return SelectedFile(file_id, Path(raw_path)) + + +def _remote_factory(url: str, token: str) -> RemoteClientFactory: + parsed = urlparse(url) + local_hosts = {"localhost", "127.0.0.1", "::1"} + if parsed.scheme != "https" and not ( + parsed.scheme == "http" and parsed.hostname in local_hosts + ): + raise ValueError("GEWU URL must use HTTPS except for loopback development") + if not parsed.netloc or parsed.username or parsed.password: + raise ValueError("GEWU URL is invalid") + + @asynccontextmanager + async def connect() -> AsyncIterator[Client]: + # Keep service identity verification explicit: remote connectors must + # authenticate the endpoint certificate before sending the bearer key. + async with httpx2.AsyncClient( + headers={"Authorization": f"Bearer {token}"}, + verify=True, + ) as http: + async with Client(streamable_http_client(url, http_client=http)) as remote: + yield remote + + return connect + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--url", required=True) + parser.add_argument("--file", action="append", type=_parse_selected_file, required=True) + parser.add_argument( + "--state-file", + type=Path, + default=Path(os.environ.get("XDG_STATE_HOME", "~/.local/state")) / "gewu" / "uploads.json", + ) + arguments = parser.parse_args() + token = os.environ.get("GEWU_TOKEN", "") + if not token or len(token) > 128 or any(char.isspace() for char in token): + parser.error("GEWU_TOKEN must contain an operator-provisioned bearer credential") + try: + server = create_connector_server( + tuple(arguments.file), + _remote_factory(arguments.url, token), + UploadStateStore(arguments.state_file), + ) + except ValueError as error: + parser.error(str(error)) + server.run("stdio") + + +if __name__ == "__main__": + main() diff --git a/src/gewu/version.py b/src/gewu/version.py new file mode 100644 index 0000000..b0daf42 --- /dev/null +++ b/src/gewu/version.py @@ -0,0 +1,3 @@ +"""GEWU service version shared by transports and diagnostics.""" + +SERVICE_VERSION = "0.1.0" diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..7eb8d62 --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1 @@ +"""GEWU test suite.""" diff --git a/tests/adapters/__init__.py b/tests/adapters/__init__.py new file mode 100644 index 0000000..0ff253f --- /dev/null +++ b/tests/adapters/__init__.py @@ -0,0 +1 @@ +"""Infrastructure adapter tests.""" diff --git a/tests/adapters/crash_sqlite_rebuild_worker.py b/tests/adapters/crash_sqlite_rebuild_worker.py new file mode 100644 index 0000000..3c8c449 --- /dev/null +++ b/tests/adapters/crash_sqlite_rebuild_worker.py @@ -0,0 +1,60 @@ +"""Exit abruptly from inside a SQLite FTS rebuild transaction.""" + +from __future__ import annotations + +import os +import sqlite3 +import sys +from pathlib import Path + +from gewu.adapters.git_repository import GitCanonicalRepository +from gewu.adapters.sqlite_search import SQLiteSearchIndex + + +class CrashConnection(sqlite3.Connection): + def __init__(self, *args: object, **kwargs: object) -> None: + super().__init__(*args, **kwargs) # type: ignore[arg-type] + self._fts_inserts = 0 + + def execute( + self, + sql: str, + parameters: tuple[object, ...] = (), + /, + ) -> sqlite3.Cursor: + cursor = super().execute(sql, parameters) + if sql.lstrip().upper().startswith("INSERT INTO NODE_FTS"): + self._fts_inserts += 1 + if self._fts_inserts == 5: + os._exit(74) + return cursor + + +class CrashDuringIndexRebuild(SQLiteSearchIndex): + def _connect(self) -> sqlite3.Connection: + connection = sqlite3.connect( + self.path, + timeout=5, + isolation_level=None, + factory=CrashConnection, + ) + connection.execute("PRAGMA foreign_keys=ON") + connection.execute("PRAGMA busy_timeout=5000") + connection.execute("PRAGMA synchronous=NORMAL") + return connection + + +def main() -> None: + repository = GitCanonicalRepository(Path(sys.argv[1])) + index = CrashDuringIndexRebuild(Path(sys.argv[2]), repository) + index.search( + query="visitors", + project_ids=("dome-performance",), + include_shared=False, + limit=20, + ) + raise RuntimeError("SQLite rebuild did not reach the requested crash point") + + +if __name__ == "__main__": + main() diff --git a/tests/adapters/test_embeddings.py b/tests/adapters/test_embeddings.py new file mode 100644 index 0000000..4411f5d --- /dev/null +++ b/tests/adapters/test_embeddings.py @@ -0,0 +1,92 @@ +from __future__ import annotations + +import hashlib +from pathlib import Path +from typing import Any + +import pytest + +from gewu.adapters.embeddings import ( + EmbeddingProviderError, + LocalSentenceTransformer, + model_artifact_sha256, +) + + +class FakeEncoder: + def __init__(self, rows: list[list[float]]) -> None: + self.rows = rows + self.seen: list[str] = [] + self.calls = 0 + + def encode( + self, + sentences: list[str], + *, + normalize_embeddings: bool, + convert_to_numpy: bool, + show_progress_bar: bool, + ) -> Any: + assert normalize_embeddings and convert_to_numpy and not show_progress_bar + self.seen.extend(sentences) + result = self.rows[self.calls : self.calls + len(sentences)] + self.calls += len(sentences) + return result + + +def _model_path(tmp_path: Path) -> Path: + path = tmp_path / "model" + path.mkdir() + (path / "config.json").write_text('{"dim":2}', encoding="utf-8") + return path + + +def test_local_sentence_transformer_applies_e5_prefixes_and_metadata(tmp_path: Path) -> None: + path = _model_path(tmp_path) + encoder = FakeEncoder([[0.6, 0.8], [1.0, 0.0]]) + artifact = model_artifact_sha256(path) + provider = LocalSentenceTransformer( + path, + model_id="intfloat/multilingual-e5-small@pinned", + expected_sha256=artifact, + dimensions=2, + encoder=encoder, + ) + + assert provider.embed_queries(("question",)) == ((0.6, 0.8),) + assert provider.embed_passages(("evidence",)) == ((1.0, 0.0),) + assert encoder.seen == ["query: question", "passage: evidence"] + assert provider.model_id == "intfloat/multilingual-e5-small@pinned" + assert provider.artifact_sha256 == artifact + assert len(provider.artifact_sha256) == 64 + + +def test_local_sentence_transformer_rejects_changed_artifact(tmp_path: Path) -> None: + path = _model_path(tmp_path) + (path / "config.json").write_text("changed", encoding="utf-8") + + with pytest.raises(EmbeddingProviderError, match="digest"): + LocalSentenceTransformer( + path, + model_id="model", + expected_sha256=hashlib.sha256(b"wrong").hexdigest(), + dimensions=2, + encoder=FakeEncoder([[1.0, 0.0]]), + ) + + +@pytest.mark.parametrize("rows", [[[2.0, 0.0]], [[float("nan"), 0.0]], [[1.0]]]) +def test_local_sentence_transformer_rejects_invalid_vectors( + tmp_path: Path, rows: list[list[float]] +) -> None: + path = _model_path(tmp_path) + provider = LocalSentenceTransformer( + path, + model_id="model", + expected_sha256=model_artifact_sha256(path), + dimensions=2, + encoder=FakeEncoder(rows), + ) + + with pytest.raises(EmbeddingProviderError): + provider.embed_passages(("text",)) diff --git a/tests/adapters/test_git_repository.py b/tests/adapters/test_git_repository.py new file mode 100644 index 0000000..c48be01 --- /dev/null +++ b/tests/adapters/test_git_repository.py @@ -0,0 +1,92 @@ +from concurrent.futures import ThreadPoolExecutor +from pathlib import Path +from threading import Barrier + +import pytest + +from gewu.adapters.git_repository import ( + GitCanonicalRepository, + GitConflictError, + GitRepositoryError, +) + + +def test_bare_repository_commits_only_explicit_files_and_checks_expected_head( + tmp_path: Path, +) -> None: + repository = GitCanonicalRepository(tmp_path / "private" / "knowledge.git") + base = repository.head() + record_path = "projects/project-a/nodes/node-a.yaml" + commit = repository.publish_files( + expected_head=base, + files={record_path: b"title: accepted\n"}, + message="Accept fixture", + index_directory=tmp_path / "staging" / "publishing", + ) + assert repository.read_file(commit, record_path) == b"title: accepted\n" + assert repository.list_files(commit, "projects") == (record_path,) + assert repository.list_files(commit, "sources") == () + assert repository.read_file(commit, "unrelated/user-file.txt") is None + assert repository.introducing_commit(commit, record_path) == commit + + with pytest.raises(GitConflictError, match="Canonical branch changed"): + repository.publish_files( + expected_head=base, + files={"projects/project-b/nodes/node-b.yaml": b"title: stale\n"}, + message="Stale writer", + index_directory=tmp_path / "staging" / "publishing", + ) + + +def test_record_paths_reject_traversal_and_noncanonical_separators(tmp_path: Path) -> None: + repository = GitCanonicalRepository(tmp_path / "knowledge.git") + for unsafe in ("../escape", "/absolute", "projects//node", "projects/./node", "a\\b"): + with pytest.raises(GitRepositoryError, match="record path is invalid"): + repository.read_file(repository.head(), unsafe) + + +def test_independent_repository_adapters_allow_one_compare_and_swap_winner( + tmp_path: Path, +) -> None: + repository_path = tmp_path / "knowledge.git" + first_adapter = GitCanonicalRepository(repository_path) + second_adapter = GitCanonicalRepository(repository_path) + base = first_adapter.head() + start = Barrier(2) + index_directory = tmp_path / "staging" / "publishing" + + def publish(repository: GitCanonicalRepository, name: str) -> tuple[str, str | None]: + path = f"projects/{name}/nodes/node.yaml" + start.wait(timeout=5) + try: + revision = repository.publish_files( + expected_head=base, + files={path: f"title: {name}\n".encode()}, + message=f"Publish {name}", + index_directory=index_directory, + ) + except GitConflictError: + return name, None + return name, revision + + with ThreadPoolExecutor(max_workers=2) as workers: + results = list( + workers.map( + lambda args: publish(*args), + ((first_adapter, "project-a"), (second_adapter, "project-b")), + ) + ) + + winners = [(name, revision) for name, revision in results if revision is not None] + losers = [name for name, revision in results if revision is None] + assert len(winners) == len(losers) == 1 + winner, winning_revision = winners[0] + assert winning_revision is not None + assert first_adapter.head() == winning_revision + assert second_adapter.head() == winning_revision + assert ( + first_adapter.read_file(winning_revision, f"projects/{winner}/nodes/node.yaml") + == f"title: {winner}\n".encode() + ) + loser = losers[0] + assert first_adapter.read_file(winning_revision, f"projects/{loser}/nodes/node.yaml") is None diff --git a/tests/adapters/test_pdf_extractor.py b/tests/adapters/test_pdf_extractor.py new file mode 100644 index 0000000..71ffbcf --- /dev/null +++ b/tests/adapters/test_pdf_extractor.py @@ -0,0 +1,113 @@ +from __future__ import annotations + +import shutil +import subprocess +import tempfile +from pathlib import Path + +import pytest + +from gewu.adapters.pdf_extractor import PdfTextExtractor + + +def _pdf(pages: tuple[str, ...]) -> bytes: + objects: list[bytes] = [] + page_ids = [4 + 2 * index for index in range(len(pages))] + objects.extend( + [ + b"<< /Type /Catalog /Pages 2 0 R >>", + ( + f"<< /Type /Pages /Kids [{' '.join(f'{page_id} 0 R' for page_id in page_ids)}] " + f"/Count {len(pages)} >>" + ).encode(), + b"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>", + ] + ) + for page_id, text in zip(page_ids, pages, strict=True): + content_id = page_id + 1 + escaped = text.replace("\\", "\\\\").replace("(", "\\(").replace(")", "\\)") + stream = f"BT /F1 12 Tf 72 720 Td ({escaped}) Tj ET".encode() + page = ( + f"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] " + f"/Resources << /Font << /F1 3 0 R >> >> /Contents {content_id} 0 R >>" + ) + objects.append(page.encode()) + objects.append( + b"<< /Length " + str(len(stream)).encode() + b" >>\nstream\n" + stream + b"\nendstream" + ) + output = bytearray(b"%PDF-1.4\n") + offsets = [0] + for number, obj in enumerate(objects, 1): + offsets.append(len(output)) + output.extend(f"{number} 0 obj\n".encode() + obj + b"\nendobj\n") + xref = len(output) + output.extend(f"xref\n0 {len(offsets)}\n0000000000 65535 f \n".encode()) + for offset in offsets[1:]: + output.extend(f"{offset:010d} 00000 n \n".encode()) + output.extend( + f"trailer\n<< /Size {len(offsets)} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n".encode() + ) + return bytes(output) + + +@pytest.mark.skipif( + not all(shutil.which(item) for item in ("bwrap", "pdftotext", "prlimit")), + reason="Linux PDF sandbox tools unavailable", +) +def test_pdf_extraction_preserves_page_locations_and_parser_identity() -> None: + record = PdfTextExtractor().extract( + "project:source:r1", + _pdf(("First page evidence.", "Second page evidence.")), + "application/pdf", + ) + assert record.status == "complete" + assert record.parser_name == "gewu-poppler-pdftotext" + assert [block.page for block in record.blocks] == [1, 2] + assert [block.original_text.strip() for block in record.blocks] == [ + "First page evidence.", + "Second page evidence.", + ] + + +@pytest.mark.skipif( + not all(shutil.which(item) for item in ("bwrap", "pdftotext", "prlimit")), + reason="Linux PDF sandbox tools unavailable", +) +def test_empty_pdf_page_is_reported_as_partial() -> None: + record = PdfTextExtractor().extract( + "project:source:r1", _pdf(("Text page.", "")), "application/pdf" + ) + assert record.status == "partial" + assert record.blocks[0].page == 1 + assert record.warnings + + +def test_pdf_parser_rejects_bad_signature_without_launching_parser() -> None: + with pytest.raises(ValueError, match="signature"): + PdfTextExtractor().extract("project:source:r1", b"not a pdf", "application/pdf") + + +@pytest.mark.skipif( + not shutil.which("bwrap") or not shutil.which("prlimit"), reason="bubblewrap unavailable" +) +def test_sandbox_cannot_read_host_temporary_file(tmp_path: Path) -> None: + sentinel = tmp_path / "gewu-host-secret-fixture" + sentinel.write_text("must-not-be-visible", encoding="utf-8") + extractor = PdfTextExtractor() + if not extractor._pdftotext: + pytest.skip("pdftotext unavailable") + command = extractor._command() + command[-6:] = ["/usr/bin/cat", str(sentinel)] + with tempfile.TemporaryFile() as output, tempfile.TemporaryFile() as errors: + result = subprocess.run( + command, + stdin=subprocess.DEVNULL, + stdout=output, + stderr=errors, + env={}, + timeout=10, + check=False, + ) + assert result.returncode != 0 + output.seek(0) + assert b"must-not-be-visible" not in output.read() diff --git a/tests/adapters/test_sqlite_identity.py b/tests/adapters/test_sqlite_identity.py new file mode 100644 index 0000000..2ec6be8 --- /dev/null +++ b/tests/adapters/test_sqlite_identity.py @@ -0,0 +1,513 @@ +import hashlib +import json +import time +from pathlib import Path + +import pyotp +import pytest +from cryptography.fernet import Fernet + +from gewu.adapters.sqlite_identity import SQLiteIdentityStore + + +def issue_challenge( + store: SQLiteIdentityStore, + *, + principal_id: str, + challenge_id: str, + domain: str, + now: int, +) -> None: + store.issue_owner_challenge( + challenge_id=challenge_id, + owner_principal_id=principal_id, + proposal_id="proposal-one", + proposal_revision=1, + payload_digest="a" * 64, + diff_digest="b" * 64, + domains=(domain,), + expires_at=now + 5 * 60, + now=now, + ) + + +def test_credentials_are_hashed_revocable_and_project_scoped(tmp_path: Path) -> None: + store = SQLiteIdentityStore(tmp_path / "private" / "identity.sqlite3") + token = store.provision( + credential_id="agent-key-1", + principal_id="agent-pilot", + roles=("agent",), + project_ids=("dome-performance",), + ) + + principal = store.authenticate(token) + assert principal is not None + assert principal.principal_id == "agent-pilot" + assert principal.credential_id == "agent-key-1" + assert principal.can_read_project("dome-performance") + assert not principal.can_read_project("interactive-video") + assert token.encode() not in (tmp_path / "private" / "identity.sqlite3").read_bytes() + assert store.revoke("agent-key-1") + assert store.authenticate(token) is None + assert not store.revoke("agent-key-1") + + +def test_admin_role_can_read_without_project_grant(tmp_path: Path) -> None: + store = SQLiteIdentityStore(tmp_path / "identity.sqlite3") + token = store.provision( + credential_id="admin-key", + principal_id="operator-1", + roles=("admin",), + ) + principal = store.authenticate(token) + assert principal is not None + assert principal.can_read_project("dome-performance") + + +def test_publication_authority_snapshot_detects_grant_or_role_change(tmp_path: Path) -> None: + store = SQLiteIdentityStore(tmp_path / "identity.sqlite3") + token = store.provision( + credential_id="agent-key", + principal_id="agent-one", + roles=("agent",), + project_ids=("dome-performance", "shared"), + ) + principal = store.authenticate(token) + assert principal is not None + assert store.is_current( + principal.principal_id, + principal.credential_id, + roles=principal.roles, + project_ids=principal.project_ids, + ) + assert not store.is_current( + principal.principal_id, + principal.credential_id, + roles=principal.roles, + project_ids=frozenset({"dome-performance"}), + ) + + +def test_rotation_revokes_old_key_and_preserves_attribution_and_grants(tmp_path: Path) -> None: + store = SQLiteIdentityStore(tmp_path / "identity.sqlite3") + old_token = store.provision( + credential_id="agent-key-old", + principal_id="agent-pilot", + roles=("agent",), + project_ids=("dome-performance",), + ) + new_token = store.rotate("agent-key-old", "agent-key-new") + assert store.authenticate(old_token) is None + principal = store.authenticate(new_token) + assert principal is not None + assert principal.principal_id == "agent-pilot" + assert principal.credential_id == "agent-key-new" + assert principal.can_read_project("dome-performance") + + +def test_browser_sessions_are_human_only_csrf_protected_and_revocable(tmp_path: Path) -> None: + store = SQLiteIdentityStore(tmp_path / "identity.sqlite3") + viewer_key = store.provision( + credential_id="viewer-key", + principal_id="viewer-one", + roles=("viewer",), + project_ids=("dome-performance",), + ) + agent_key = store.provision( + credential_id="agent-key", + principal_id="agent-one", + roles=("agent",), + project_ids=("dome-performance",), + ) + with pytest.raises(PermissionError, match="human browser identity"): + store.create_browser_session(agent_key) + + session = store.create_browser_session(viewer_key) + principal = store.authenticate_browser_session(session.session_token) + assert principal is not None + assert principal.auth_method == "browser_session" + assert principal.can_read_project("dome-performance") + assert not principal.can_submit_project("dome-performance") + assert store.browser_csrf_valid(session.session_token, session.csrf_token) + assert not store.browser_csrf_valid(session.session_token, "gwc_invalid") + assert store.revoke_browser_session(session.session_token) + assert store.authenticate_browser_session(session.session_token) is None + assert not store.browser_csrf_valid(session.session_token, session.csrf_token) + + admin_key = store.provision( + credential_id="human-admin-key", + principal_id="human-admin", + roles=("admin",), + project_ids=("dome-performance",), + ) + admin_session = store.create_browser_session(admin_key) + browser_admin = store.authenticate_browser_session(admin_session.session_token) + assert browser_admin is not None and not browser_admin.can_submit_project("dome-performance") + + +def test_revoking_browser_credential_invalidates_its_sessions(tmp_path: Path) -> None: + store = SQLiteIdentityStore(tmp_path / "identity.sqlite3") + token = store.provision( + credential_id="human-key", + principal_id="human-one", + roles=("editor",), + project_ids=("dome-performance",), + ) + session = store.create_browser_session(token) + assert store.authenticate_browser_session(session.session_token) is not None + assert store.revoke("human-key") + assert store.authenticate_browser_session(session.session_token) is None + + +def test_identity_store_rejects_symlinked_database_path(tmp_path: Path) -> None: + target = tmp_path / "real" + target.mkdir() + link = tmp_path / "linked" + link.symlink_to(target, target_is_directory=True) + with pytest.raises(ValueError, match="symbolic links"): + SQLiteIdentityStore(link / "identity.sqlite3") + + +def test_owner_session_is_challenge_bound_short_lived_and_separate_from_agent_auth( + tmp_path: Path, +) -> None: + store = SQLiteIdentityStore( + tmp_path / "identity.sqlite3", owner_encryption_key=Fernet.generate_key() + ) + secret = pyotp.random_base32() + store.provision_owner( + principal_id="project-owner", + password="correct horse battery staple", + totp_secret=secret, + project_ids=("dome-performance",), + ) + database_bytes = (tmp_path / "identity.sqlite3").read_bytes() + assert secret.encode() not in database_bytes + assert b"correct horse battery staple" not in database_bytes + now = int(time.time()) + issue_challenge( + store, + principal_id="project-owner", + challenge_id="challenge-1", + domain="dome-performance", + now=now, + ) + session = store.authenticate_owner( + principal_id="project-owner", + password="correct horse battery staple", + totp_code=pyotp.TOTP(secret).at(now), + challenge_id="challenge-1", + audience="https://gewu.example", + now=now, + ) + assert session is not None + assert session.expires_at.timestamp() == now + 5 * 60 + principal = store.authenticate_owner_session( + session.session_token, + challenge_id="challenge-1", + audience="https://gewu.example", + ) + assert principal is not None + assert principal.roles == frozenset({"owner"}) + assert principal.auth_method == "owner_session" + assert principal.can_read_project("dome-performance") + assert not principal.can_submit_project("dome-performance") + assert store.authenticate(session.session_token) is None + assert ( + store.authenticate_owner_session( + session.session_token, + challenge_id="challenge-2", + audience="https://gewu.example", + ) + is None + ) + assert ( + store.authenticate_owner_session( + session.session_token, + challenge_id="challenge-1", + audience="https://other.example", + ) + is None + ) + + +def test_owner_totp_cannot_be_replayed_and_login_failures_are_rate_limited( + tmp_path: Path, +) -> None: + store = SQLiteIdentityStore( + tmp_path / "identity.sqlite3", owner_encryption_key=Fernet.generate_key() + ) + secret = pyotp.random_base32() + store.provision_owner( + principal_id="project-owner", + password="correct horse battery staple", + totp_secret=secret, + project_ids=("dome-performance",), + ) + now = int(time.time()) + issue_challenge( + store, + principal_id="project-owner", + challenge_id="challenge-1", + domain="dome-performance", + now=now, + ) + code = pyotp.TOTP(secret).at(now) + args = { + "principal_id": "project-owner", + "password": "correct horse battery staple", + "totp_code": code, + "challenge_id": "challenge-1", + "audience": "https://gewu.example", + "now": now, + } + assert store.authenticate_owner(**args) is not None + assert store.authenticate_owner(**args) is None + + for attempt in range(5): + challenge_id = f"challenge-bad-{attempt}" + issue_challenge( + store, + principal_id="project-owner", + challenge_id=challenge_id, + domain="dome-performance", + now=now, + ) + assert ( + store.authenticate_owner( + principal_id="project-owner", + password="incorrect password", + totp_code="000000", + challenge_id=challenge_id, + audience="https://gewu.example", + now=now, + ) + is None + ) + issue_challenge( + store, + principal_id="project-owner", + challenge_id="challenge-final", + domain="dome-performance", + now=now, + ) + assert ( + store.authenticate_owner( + principal_id="project-owner", + password="incorrect password", + totp_code="000000", + challenge_id="challenge-final", + audience="https://gewu.example", + now=now, + ) + is None + ) + + +def test_owner_operations_stay_disabled_without_encryption_key(tmp_path: Path) -> None: + store = SQLiteIdentityStore(tmp_path / "identity.sqlite3") + with pytest.raises(RuntimeError, match="encryption key is not configured"): + store.provision_owner( + principal_id="project-owner", + password="correct horse battery staple", + totp_secret=pyotp.random_base32(), + project_ids=(), + ) + assert ( + store.authenticate_owner( + principal_id="project-owner", + password="correct horse battery staple", + totp_code="000000", + challenge_id="challenge-1", + audience="https://gewu.example", + ) + is None + ) + + +def test_owner_challenge_retry_is_bound_to_payload_diff_and_granted_domains( + tmp_path: Path, +) -> None: + store = SQLiteIdentityStore( + tmp_path / "identity.sqlite3", owner_encryption_key=Fernet.generate_key() + ) + store.provision_owner( + principal_id="project-owner", + password="correct horse battery staple", + totp_secret=pyotp.random_base32(), + project_ids=("dome-performance",), + ) + now = int(time.time()) + issue_challenge( + store, + principal_id="project-owner", + challenge_id="challenge-1", + domain="dome-performance", + now=now, + ) + issue_challenge( + store, + principal_id="project-owner", + challenge_id="challenge-1", + domain="dome-performance", + now=now, + ) + with pytest.raises(ValueError, match="different content"): + store.issue_owner_challenge( + challenge_id="challenge-1", + owner_principal_id="project-owner", + proposal_id="proposal-one", + proposal_revision=1, + payload_digest="c" * 64, + diff_digest="b" * 64, + domains=("dome-performance",), + expires_at=now + 300, + now=now, + ) + + +def test_owner_decision_is_exact_diff_bound_and_idempotent(tmp_path: Path) -> None: + store = SQLiteIdentityStore( + tmp_path / "identity.sqlite3", owner_encryption_key=Fernet.generate_key() + ) + secret = pyotp.random_base32() + store.provision_owner( + principal_id="project-owner", + password="correct horse battery staple", + totp_secret=secret, + project_ids=("dome-performance",), + ) + now = int(time.time()) + diff_json = json.dumps({"files": [{"path": "projects/dome/nodes/a.yaml"}]}) + diff_digest = hashlib.sha256(diff_json.encode()).hexdigest() + store.issue_owner_challenge( + challenge_id="challenge-bound", + owner_principal_id="project-owner", + proposal_id="proposal-one", + proposal_revision=1, + payload_digest="a" * 64, + diff_digest=diff_digest, + domains=("dome-performance",), + expires_at=now + 300, + diff_json=diff_json, + now=now, + ) + token = store.authenticate_owner( + principal_id="project-owner", + password="correct horse battery staple", + totp_code=pyotp.TOTP(secret).at(now), + challenge_id="challenge-bound", + audience="gewu-test", + now=now, + ) + assert token is not None + assert ( + store.authenticate_owner_session( + token.session_token, challenge_id="challenge-bound", audience="gewu-test" + ) + is not None + ) + challenge = store.get_owner_challenge("challenge-bound", principal_id="project-owner") + assert challenge is not None + assert challenge["diff_json"] == diff_json + + arguments = { + "challenge_id": "challenge-bound", + "principal_id": "project-owner", + "proposal_revision": 1, + "payload_digest": "a" * 64, + "diff_digest": diff_digest, + "decision": "authorize", + "rationale": "Reviewed exact proposed files and approve the policy exception.", + "owner_session_id": hashlib.sha256(token.session_token.encode()).hexdigest(), + "audience": "gewu-test", + "now": now, + } + with pytest.raises(ValueError, match="does not match"): + store.decide_owner_challenge(**{**arguments, "diff_digest": "b" * 64}) + with pytest.raises(PermissionError, match="authority is unavailable"): + store.decide_owner_challenge(**{**arguments, "owner_session_id": "0" * 64}) + first = store.decide_owner_challenge(**arguments) + assert first["status"] == "authorized" + assert store.decide_owner_challenge(**arguments) == first + with pytest.raises(ValueError, match="different decision"): + store.decide_owner_challenge(**{**arguments, "decision": "reject"}) + with pytest.raises(PermissionError, match="every affected domain"): + store.issue_owner_challenge( + challenge_id="challenge-out-of-scope", + owner_principal_id="project-owner", + proposal_id="proposal-one", + proposal_revision=1, + payload_digest="a" * 64, + diff_digest="b" * 64, + domains=("interactive-video",), + expires_at=now + 300, + now=now, + ) + + +def test_owner_grant_change_and_revocation_invalidate_sessions(tmp_path: Path) -> None: + store = SQLiteIdentityStore( + tmp_path / "identity.sqlite3", owner_encryption_key=Fernet.generate_key() + ) + secret = pyotp.random_base32() + store.provision_owner( + principal_id="project-owner", + password="correct horse battery staple", + totp_secret=secret, + project_ids=("dome-performance",), + ) + now = int(time.time()) + issue_challenge( + store, + principal_id="project-owner", + challenge_id="challenge-1", + domain="dome-performance", + now=now, + ) + first = store.authenticate_owner( + principal_id="project-owner", + password="correct horse battery staple", + totp_code=pyotp.TOTP(secret).at(now), + challenge_id="challenge-1", + audience="https://gewu.example", + now=now, + ) + assert first is not None + assert store.update_owner_grants("project-owner", ("interactive-video",)) + assert ( + store.authenticate_owner_session( + first.session_token, + challenge_id="challenge-1", + audience="https://gewu.example", + ) + is None + ) + + second_counter = now // 30 + 1 + second_now = second_counter * 30 + issue_challenge( + store, + principal_id="project-owner", + challenge_id="challenge-2", + domain="interactive-video", + now=second_now, + ) + second = store.authenticate_owner( + principal_id="project-owner", + password="correct horse battery staple", + totp_code=pyotp.TOTP(secret).generate_otp(second_counter), + challenge_id="challenge-2", + audience="https://gewu.example", + now=second_now, + ) + assert second is not None + assert store.revoke_owner("project-owner") + assert ( + store.authenticate_owner_session( + second.session_token, + challenge_id="challenge-2", + audience="https://gewu.example", + ) + is None + ) diff --git a/tests/adapters/test_sqlite_search.py b/tests/adapters/test_sqlite_search.py new file mode 100644 index 0000000..6dc2e61 --- /dev/null +++ b/tests/adapters/test_sqlite_search.py @@ -0,0 +1,702 @@ +import hashlib +import sqlite3 +import subprocess +import sys +from collections.abc import Callable +from datetime import UTC, date, datetime +from pathlib import Path +from typing import cast + +import pytest +import yaml + +from gewu.adapters.git_repository import GitCanonicalRepository +from gewu.adapters.sqlite_search import SQLiteSearchIndex +from gewu.application.identity import Principal +from gewu.application.search import CanonicalSearchService, SearchError +from gewu.domain.contracts import ( + Confidence, + EdgeEndpoint, + KnowledgeNode, + NeighborRequest, + NodeType, + Scope, + SearchRequest, + TypedEdge, +) +from gewu.domain.validation import assertion_digest, evidence_digest +from tests.domain.proposal_fixtures import NODE, PROJECTS, SOURCE_REVISION + + +def _node_path(node: KnowledgeNode) -> str: + project_key = hashlib.sha256(node.project_id.encode()).hexdigest() + node_key = hashlib.sha256(node.node_id.encode()).hexdigest() + return f"projects/{project_key}/nodes/{node_key}.yaml" + + +def _publish_nodes( + repository: GitCanonicalRepository, directory: Path, nodes: tuple[KnowledgeNode, ...] +) -> str: + return repository.publish_files( + expected_head=repository.head(), + files={ + _node_path(node): yaml.safe_dump( + node.model_dump(mode="json"), allow_unicode=True + ).encode() + for node in nodes + }, + message="Publish search fixtures", + index_directory=directory, + ) + + +def _service( + repository: GitCanonicalRepository, + index_path: Path, + *, + head: Callable[[], str] | None = None, +) -> CanonicalSearchService: + return CanonicalSearchService( + index=SQLiteSearchIndex(index_path, repository), + project_ids_provider=lambda: PROJECTS.keys(), + canonical_head=head or repository.head, + ) + + +def test_fts_search_enforces_explicit_project_and_shared_scope(tmp_path: Path) -> None: + repository = GitCanonicalRepository(tmp_path / "knowledge.git") + stale = NODE.model_copy( + update={ + "as_of": datetime(2024, 1, 1, tzinfo=UTC), + "stale_after": datetime(2025, 1, 1, tzinfo=UTC), + } + ) + video_node = NODE.model_copy( + update={ + "node_id": "video:branching-workflow", + "project_id": "interactive-video", + "title": "Interactive branching workflow", + "summary": "Video authors control branching and state growth.", + } + ) + shared_node = NODE.model_copy( + update={ + "node_id": "shared:visitor-benchmark", + "project_id": "shared", + "title": "Shared visitor benchmark", + } + ) + _publish_nodes(repository, tmp_path / "publishing", (stale, video_node, shared_node)) + service = _service(repository, tmp_path / "search.sqlite3") + dome_reader = Principal( + "reader-dome", "reader-key", frozenset({"viewer"}), frozenset({"dome-performance"}) + ) + dome_result = service.search( + SearchRequest( + query="visitors", + scope=Scope(projects=("dome-performance",), include_shared=False), + budget_tokens=5000, + ), + principal=dome_reader, + ) + assert [cast(dict[str, object], row["node"])["node_id"] for row in dome_result.data] == [ + NODE.node_id + ] + assert any(warning.code == "STALE_NODE" for warning in dome_result.warnings) + assert dome_result.retrieval_mode == "keyword" + assert dome_result.canonical_revision == dome_result.index_revision + assert dome_result.tokens_used > 0 + bounded = service.search( + SearchRequest( + query="visitors", + scope=Scope(projects=("dome-performance",), include_shared=False), + budget_tokens=1500, + ), + principal=dome_reader, + ) + assert bounded.data == () + assert bounded.citations == () + assert bounded.omissions + assert bounded.warnings + assert bounded.next_cursor is not None + recovered = service.search( + SearchRequest( + query="visitors", + scope=Scope(projects=("dome-performance",), include_shared=False), + cursor=bounded.next_cursor, + budget_tokens=5000, + ), + principal=dome_reader, + ) + recovered_node = cast(dict[str, object], recovered.data[0]["node"]) + assert recovered_node["node_id"] == NODE.node_id + with pytest.raises(SearchError, match="too small"): + service.search( + SearchRequest( + query="visitors", + scope=Scope(projects=("dome-performance",), include_shared=False), + budget_tokens=256, + ), + principal=dome_reader, + ) + with pytest.raises(SearchError, match="shared knowledge"): + service.search( + SearchRequest( + query="visitors", + scope=Scope(projects=("dome-performance",)), + budget_tokens=5000, + ), + principal=dome_reader, + ) + with pytest.raises(SearchError, match="every requested project"): + service.search( + SearchRequest( + query='visitors" OR *', + scope=Scope( + projects=("dome-performance", "interactive-video"), include_shared=False + ), + budget_tokens=5000, + ), + principal=dome_reader, + ) + + +def test_assumption_and_limitation_claims_are_searchable_and_cited(tmp_path: Path) -> None: + original_claim = NODE.claims[0] + statement = "The assumptionspecific condition affects seasonal visitor counts." + statement_digest = assertion_digest(statement) + evidence = original_claim.evidence[0].model_copy( + update={ + "assertion_path": "assumptions[0].text", + "assertion_digest": statement_digest, + } + ) + assessment = original_claim.assessment.model_copy( + update={ + "assertion_digest": statement_digest, + "evidence_digests": (evidence_digest(evidence),), + } + ) + context_claim = original_claim.model_copy( + update={ + "claim_id": "dome:assumption-search-test", + "text": statement, + "evidence": (evidence,), + "assessment": assessment, + } + ) + limitation_evidence = evidence.model_copy(update={"assertion_path": "limitations[0].text"}) + limitation_claim = context_claim.model_copy( + update={ + "claim_id": "dome:limitation-search-test", + "evidence": (limitation_evidence,), + "assessment": assessment.model_copy( + update={"evidence_digests": (evidence_digest(limitation_evidence),)} + ), + } + ) + contextual_node = NODE.model_copy( + update={"assumptions": (context_claim,), "limitations": (limitation_claim,)} + ) + repository = GitCanonicalRepository(tmp_path / "knowledge.git") + _publish_nodes(repository, tmp_path / "publishing", (contextual_node,)) + service = _service(repository, tmp_path / "search.sqlite3") + reader = Principal( + "reader-dome", "reader-key", frozenset({"viewer"}), frozenset({"dome-performance"}) + ) + + result = service.search( + SearchRequest( + query="assumptionspecific", + scope=Scope(projects=("dome-performance",), include_shared=False), + budget_tokens=50_000, + ), + principal=reader, + ) + found = cast(dict[str, object], result.data[0]["node"]) + assert found["assumptions"][0]["text"] == statement + assert found["limitations"][0]["text"] == statement + assert any(item.quote == evidence.quote for item in result.citations) + + +def test_fts_search_indexes_entity_aliases(tmp_path: Path) -> None: + repository = GitCanonicalRepository(tmp_path / "knowledge.git") + entity = NODE.model_copy( + update={ + "node_type": NodeType.ENTITY, + "node_id": "dome:planetarium-entity", + "title": "Dome installation", + "aliases": ("Planetarium",), + } + ) + _publish_nodes(repository, tmp_path / "publishing", (entity,)) + service = _service(repository, tmp_path / "search.sqlite3") + reader = Principal( + "reader-dome", "reader-key", frozenset({"viewer"}), frozenset({"dome-performance"}) + ) + result = service.search( + SearchRequest( + query="Planetarium", + scope=Scope(projects=("dome-performance",), include_shared=False), + budget_tokens=5000, + ), + principal=reader, + ) + assert [cast(dict[str, object], row["node"])["node_id"] for row in result.data] == [ + entity.node_id + ] + + +def test_fts_search_applies_node_type_and_freshness_facets_in_sql(tmp_path: Path) -> None: + repository = GitCanonicalRepository(tmp_path / "knowledge.git") + stale_problem = NODE.model_copy( + update={ + "node_id": "dome:stale-visitor-problem", + "node_type": NodeType.PROBLEM, + "title": "Visitor count reporting problem", + "as_of": datetime(2023, 1, 1, tzinfo=UTC), + "stale_after": datetime(2024, 1, 1, tzinfo=UTC), + "value": None, + "unit": None, + } + ) + fresh_finding = NODE.model_copy( + update={ + "node_id": "dome:fresh-visitor-finding", + "node_type": NodeType.FINDING, + "title": "Visitor count finding", + "as_of": None, + "stale_after": None, + "value": None, + "unit": None, + "sample_size": None, + "reporting_year": None, + "tags": ("demo-only",), + "audience": ("sales",), + "use_for": ("demo",), + "domain": ("video-production",), + } + ) + _publish_nodes(repository, tmp_path / "publishing", (stale_problem, fresh_finding)) + index = SQLiteSearchIndex(tmp_path / "search.sqlite3", repository) + + problem_hits = index.search( + query="visitor", + project_ids=("dome-performance",), + include_shared=False, + limit=10, + node_types=("problem",), + ) + assert [item.node.node_id for item in problem_hits.nodes] == [stale_problem.node_id] + + browse_hits = index.search( + query="", + project_ids=("dome-performance",), + include_shared=False, + limit=10, + node_types=("problem",), + ) + assert [item.node.node_id for item in browse_hits.nodes] == [stale_problem.node_id] + + stale_hits = index.search( + query="visitor", + project_ids=("dome-performance",), + include_shared=False, + limit=10, + stale_only=True, + ) + assert [item.node.node_id for item in stale_hits.nodes] == [stale_problem.node_id] + + scoped_facet_hits = index.search( + query="visitor", + project_ids=("dome-performance",), + include_shared=False, + limit=10, + tags=("attendance",), + audience=("product",), + use_for=("comparison",), + domain=("production",), + ) + assert [item.node.node_id for item in scoped_facet_hits.nodes] == [stale_problem.node_id] + + or_facet_hits = index.search( + query="visitor", + project_ids=("dome-performance",), + include_shared=False, + limit=10, + tags=("attendance", "demo-only"), + ) + assert {item.node.node_id for item in or_facet_hits.nodes} == { + stale_problem.node_id, + fresh_finding.node_id, + } + + confidence_date_hits = index.search( + query="visitor", + project_ids=("dome-performance",), + include_shared=False, + limit=10, + confidences=(Confidence.MEDIUM,), + as_of_from=date(2023, 1, 1), + as_of_to=date(2023, 12, 31), + ) + assert [item.node.node_id for item in confidence_date_hits.nodes] == [stale_problem.node_id] + + mismatched_confidence_hits = index.search( + query="visitor", + project_ids=("dome-performance",), + include_shared=False, + limit=10, + confidences=(Confidence.LOW,), + as_of_from=date(2023, 1, 1), + as_of_to=date(2023, 12, 31), + ) + assert not mismatched_confidence_hits.nodes + + +def test_typed_browse_uses_scoped_non_overlapping_cursor_pages(tmp_path: Path) -> None: + repository = GitCanonicalRepository(tmp_path / "knowledge.git") + first = NODE.model_copy( + update={ + "node_id": "dome:problem-a", + "node_type": NodeType.PROBLEM, + "title": "Problem A", + "value": None, + "unit": None, + } + ) + second = NODE.model_copy( + update={ + "node_id": "dome:problem-b", + "node_type": NodeType.PROBLEM, + "title": "Problem B", + "value": None, + "unit": None, + } + ) + other_project = NODE.model_copy( + update={ + "node_id": "video:problem-c", + "project_id": "interactive-video", + "node_type": NodeType.PROBLEM, + "title": "Problem C", + "value": None, + "unit": None, + } + ) + _publish_nodes(repository, tmp_path / "publishing", (first, second, other_project)) + service = _service(repository, tmp_path / "search.sqlite3") + reader = Principal( + "reader-dome", "reader-key", frozenset({"viewer"}), frozenset({"dome-performance"}) + ) + request = SearchRequest( + query="", + scope=Scope(projects=("dome-performance",), include_shared=False), + node_types=(NodeType.PROBLEM,), + limit=1, + budget_tokens=5000, + ) + + page_one = service.search(request, principal=reader) + page_two = service.search( + request.model_copy(update={"cursor": page_one.next_cursor}), principal=reader + ) + + ids = [ + cast(dict[str, object], row["node"])["node_id"] for row in (*page_one.data, *page_two.data) + ] + assert ids == [first.node_id, second.node_id] + assert page_one.next_cursor is not None + assert page_two.next_cursor is None + + +def test_search_index_rebuilds_from_git_after_index_deletion_and_new_revision( + tmp_path: Path, +) -> None: + repository = GitCanonicalRepository(tmp_path / "knowledge.git") + _publish_nodes(repository, tmp_path / "publishing", (NODE,)) + index_path = tmp_path / "search.sqlite3" + index = SQLiteSearchIndex(index_path, repository) + first = index.search( + query="visitors", + project_ids=("dome-performance",), + include_shared=False, + limit=10, + ) + assert [item.node.node_id for item in first.nodes] == [NODE.node_id] + + video_node = NODE.model_copy( + update={ + "node_id": "video:branching-workflow", + "project_id": "interactive-video", + "title": "Interactive video branches", + "summary": "Branching is costly for video authors.", + } + ) + new_revision = _publish_nodes(repository, tmp_path / "publishing", (video_node,)) + second = index.search( + query="branches", + project_ids=("interactive-video",), + include_shared=False, + limit=10, + ) + assert second.index_revision == new_revision + assert [item.node.node_id for item in second.nodes] == [video_node.node_id] + + rebuilt = SQLiteSearchIndex(tmp_path / "rebuilt-search.sqlite3", repository).search( + query="visitors", + project_ids=("dome-performance",), + include_shared=False, + limit=10, + ) + assert rebuilt.index_revision == new_revision + assert [item.node.node_id for item in rebuilt.nodes] == [NODE.node_id] + + +def test_os_termination_inside_index_rebuild_rolls_back_before_retrying( + tmp_path: Path, +) -> None: + repository = GitCanonicalRepository(tmp_path / "knowledge.git") + nodes = tuple( + NODE.model_copy( + update={ + "node_id": f"dome:rebuild-{index:02d}", + "title": f"Visitor evidence {index:02d}", + "summary": f"The installation hosted visitors in fixture record {index:02d}.", + } + ) + for index in range(8) + ) + first_revision = _publish_nodes(repository, tmp_path / "publishing", nodes) + index_path = tmp_path / "search.sqlite3" + index = SQLiteSearchIndex(index_path, repository) + initial = index.search( + query="visitors", + project_ids=("dome-performance",), + include_shared=False, + limit=20, + ) + assert len(initial.nodes) == len(nodes) + + added_nodes = tuple( + NODE.model_copy( + update={ + "node_id": f"dome:rebuild-{index:02d}", + "title": f"Visitor evidence {index:02d}", + "summary": f"The installation hosted visitors in fixture record {index:02d}.", + } + ) + for index in range(8, 12) + ) + all_nodes = (*nodes, *added_nodes) + latest_revision = _publish_nodes(repository, tmp_path / "publishing", added_nodes) + + crashed = subprocess.run( + [ + sys.executable, + "-m", + "tests.adapters.crash_sqlite_rebuild_worker", + str(repository.path), + str(index_path), + ], + cwd=Path(__file__).parents[2], + capture_output=True, + check=False, + timeout=60, + ) + assert crashed.returncode == 74, crashed.stderr.decode("utf-8", errors="replace") + + with sqlite3.connect(index_path) as connection: + assert connection.execute("SELECT count(*) FROM indexed_nodes").fetchone() == (len(nodes),) + assert connection.execute("SELECT count(*) FROM node_fts").fetchone() == (len(nodes),) + prior_revision = connection.execute("SELECT revision FROM index_state").fetchone() + assert prior_revision == (first_revision,) + + recovered = SQLiteSearchIndex(index_path, repository) + result = recovered.search( + query="visitors", + project_ids=("dome-performance",), + include_shared=False, + limit=20, + ) + assert {hit.node.node_id for hit in result.nodes} == {node.node_id for node in all_nodes} + assert result.index_revision == latest_revision + assert recovered.diagnostic_status()[0] == latest_revision + + +def test_search_discloses_canonical_revision_advancing_during_the_query( + tmp_path: Path, +) -> None: + repository = GitCanonicalRepository(tmp_path / "knowledge.git") + _publish_nodes(repository, tmp_path / "publishing", (NODE,)) + video_node = NODE.model_copy( + update={ + "node_id": "video:branching-workflow", + "project_id": "interactive-video", + "title": "Interactive video branching", + "summary": "Authors control video branches.", + } + ) + advanced = False + + def canonical_head_after_index() -> str: + nonlocal advanced + if not advanced: + advanced = True + _publish_nodes(repository, tmp_path / "publishing", (video_node,)) + return repository.head() + + service = _service( + repository, + tmp_path / "search.sqlite3", + head=canonical_head_after_index, + ) + principal = Principal( + "reader-dome", "reader-key", frozenset({"viewer"}), frozenset({"dome-performance"}) + ) + response = service.search( + SearchRequest( + query="visitors", + scope=Scope(projects=("dome-performance",), include_shared=False), + budget_tokens=5000, + ), + principal=principal, + ) + assert response.index_revision != response.canonical_revision + assert any(item.code == "INDEX_LAG" for item in response.warnings) + + +def test_search_cursor_expires_when_the_index_revision_changes(tmp_path: Path) -> None: + repository = GitCanonicalRepository(tmp_path / "knowledge.git") + second = NODE.model_copy(update={"node_id": "z:season-visitors"}) + _publish_nodes(repository, tmp_path / "publishing", (NODE, second)) + service = _service(repository, tmp_path / "search.sqlite3") + reader = Principal("reader", "key", frozenset({"viewer"}), frozenset({NODE.project_id})) + request = SearchRequest( + query="visitors", + scope=Scope(projects=(NODE.project_id,), include_shared=False), + limit=1, + budget_tokens=5000, + ) + first_page = service.search(request, principal=reader) + assert first_page.next_cursor is not None + + third = NODE.model_copy(update={"node_id": "zz:season-visitors"}) + _publish_nodes(repository, tmp_path / "publishing", (third,)) + with pytest.raises(SearchError, match="cursor expired") as error: + service.search( + request.model_copy(update={"cursor": first_page.next_cursor}), principal=reader + ) + assert error.value.status_code == 409 + + +def test_neighbors_are_rebuilt_and_hide_cross_project_nodes(tmp_path: Path) -> None: + repository = GitCanonicalRepository(tmp_path / "knowledge.git") + other = NODE.model_copy(update={"node_id": "video:other", "project_id": "interactive-video"}) + edge = TypedEdge( + edge_id="edge:cross-project", + edge_type="compared_with", + source=EdgeEndpoint(kind="node", id=NODE.node_id), + target=EdgeEndpoint(kind="node", id=other.node_id), + ) + files = { + _node_path(node): yaml.safe_dump(node.model_dump(mode="json")).encode() + for node in (NODE, other) + } + files[f"edges/{hashlib.sha256(edge.edge_id.encode()).hexdigest()}.yaml"] = yaml.safe_dump( + edge.model_dump(mode="json") + ).encode() + repository.publish_files( + expected_head=repository.head(), + files=files, + message="Publish typed relationship", + index_directory=tmp_path / "publishing", + ) + index = SQLiteSearchIndex(tmp_path / "search.sqlite3", repository) + hits = index.neighbors( + node_id=NODE.node_id, + edge_types=(), + project_ids=(NODE.project_id, other.project_id), + include_shared=False, + limit=10, + ) + assert hits.origin is not None and hits.origin.node_id == NODE.node_id + assert len(hits.edges) == 1 + assert hits.edges[0].neighbor_node is not None + + service = _service(repository, tmp_path / "search2.sqlite3") + reader = Principal("reader", "key", frozenset({"viewer"}), frozenset({NODE.project_id})) + response = service.neighbors( + NeighborRequest( + node_id=NODE.node_id, + scope=Scope(projects=(NODE.project_id,), include_shared=False), + budget_tokens=5000, + ), + principal=reader, + ) + assert response.data == () + assert response.citations == () + + +def test_neighbor_source_metadata_requires_an_ingested_for_scope(tmp_path: Path) -> None: + repository = GitCanonicalRepository(tmp_path / "knowledge.git") + edge = TypedEdge( + edge_id="edge:zzz-visible-source", + edge_type="derived_from", + source=EdgeEndpoint(kind="node", id=NODE.node_id), + target=EdgeEndpoint(kind="source", id=SOURCE_REVISION.revision_id), + ) + private_source = SOURCE_REVISION.model_copy( + update={ + "source_id": "video:private", + "revision_id": "video:private:r1", + "ingested_for": ("interactive-video",), + } + ) + private_edge = TypedEdge( + edge_id="edge:aaa-private-source", + edge_type="derived_from", + source=EdgeEndpoint(kind="node", id=NODE.node_id), + target=EdgeEndpoint(kind="source", id=private_source.revision_id), + ) + source_key = hashlib.sha256(SOURCE_REVISION.source_id.encode()).hexdigest() + revision_key = hashlib.sha256(SOURCE_REVISION.revision_id.encode()).hexdigest() + private_source_key = hashlib.sha256(private_source.source_id.encode()).hexdigest() + private_revision_key = hashlib.sha256(private_source.revision_id.encode()).hexdigest() + files = { + _node_path(NODE): yaml.safe_dump(NODE.model_dump(mode="json")).encode(), + f"sources/{source_key}/{revision_key}.yaml": yaml.safe_dump( + SOURCE_REVISION.model_dump(mode="json") + ).encode(), + f"sources/{private_source_key}/{private_revision_key}.yaml": yaml.safe_dump( + private_source.model_dump(mode="json") + ).encode(), + f"edges/{hashlib.sha256(edge.edge_id.encode()).hexdigest()}.yaml": yaml.safe_dump( + edge.model_dump(mode="json") + ).encode(), + f"edges/{hashlib.sha256(private_edge.edge_id.encode()).hexdigest()}.yaml": yaml.safe_dump( + private_edge.model_dump(mode="json") + ).encode(), + } + repository.publish_files( + expected_head=repository.head(), + files=files, + message="Publish source relationship", + index_directory=tmp_path / "publishing", + ) + service = _service(repository, tmp_path / "search.sqlite3") + reader = Principal("reader", "key", frozenset({"viewer"}), frozenset({NODE.project_id})) + response = service.neighbors( + NeighborRequest( + node_id=NODE.node_id, + scope=Scope(projects=(NODE.project_id,), include_shared=False), + limit=1, + budget_tokens=5000, + ), + principal=reader, + ) + assert len(response.data) == 1 + assert response.next_cursor is None + neighbor = cast(dict[str, object], response.data[0]["neighbor"]) + assert neighbor["kind"] == "source" + assert cast(dict[str, object], neighbor["record"])["revision_id"] == SOURCE_REVISION.revision_id diff --git a/tests/adapters/test_sqlite_vectors.py b/tests/adapters/test_sqlite_vectors.py new file mode 100644 index 0000000..5965d6f --- /dev/null +++ b/tests/adapters/test_sqlite_vectors.py @@ -0,0 +1,108 @@ +from pathlib import Path + +import pytest + +from gewu.adapters.sqlite_vectors import SQLiteVectorIndex, VectorIndexError + + +def _vectors() -> tuple[tuple[str, str, tuple[float, ...]], ...]: + return ( + ("dome-performance", "dome:closest", (1.0, 0.0, 0.0)), + ("interactive-video", "video:closest", (0.0, 1.0, 0.0)), + ("shared", "shared:closest", (0.9701425001, 0.242535625, 0.0)), + ) + + +def _index(path: Path) -> SQLiteVectorIndex: + pytest.importorskip("sqlite_vec") + return SQLiteVectorIndex(path) + + +def _build(index: SQLiteVectorIndex, generation_id: str, vectors=None) -> None: + index.build_generation( + generation_id=generation_id, + revision="a" * 40, + model_id="test-model@revision", + artifact_sha256="b" * 64, + dimensions=3, + vectors=_vectors() if vectors is None else vectors, + ) + + +def test_vector_generation_is_scoped_and_switches_atomically(tmp_path: Path) -> None: + index = _index(tmp_path / "vectors.sqlite3") + _build(index, "generation-1") + + assert index.activate("generation-1", expected_active=None) is None + active = index.active_generation() + assert active is not None + assert active.generation_id == "generation-1" + assert active.revision == "a" * 40 + assert active.model_id == "test-model@revision" + assert active.artifact_sha256 == "b" * 64 + assert active.dimensions == 3 + assert active.node_count == 3 + scoped = index.search( + query=(1.0, 0.0, 0.0), + project_ids=("dome-performance",), + include_shared=False, + limit=10, + ) + assert [hit.node_id for hit in scoped] == ["dome:closest"] + with_shared = index.search( + query=(1.0, 0.0, 0.0), + project_ids=("dome-performance",), + include_shared=True, + limit=10, + ) + assert {hit.node_id for hit in with_shared} == {"dome:closest", "shared:closest"} + + _build( + index, "generation-2", vectors=(("dome-performance", "dome:replacement", (1.0, 0.0, 0.0)),) + ) + with pytest.raises(VectorIndexError, match="changed"): + index.activate("generation-2", expected_active=None) + active = index.active_generation() + assert active is not None and active.generation_id == "generation-1" + assert index.activate("generation-2", expected_active="generation-1") == "generation-1" + assert [ + hit.node_id + for hit in index.search( + query=(1.0, 0.0, 0.0), + project_ids=("dome-performance",), + include_shared=False, + limit=10, + ) + ] == ["dome:replacement"] + + assert index.activate("generation-1", expected_active="generation-2") == "generation-2" + assert [ + hit.node_id + for hit in index.search( + query=(1.0, 0.0, 0.0), + project_ids=("dome-performance",), + include_shared=False, + limit=10, + ) + ] == ["dome:closest"] + with pytest.raises(VectorIndexError, match="Active vector generation"): + index.delete_generation("generation-1") + index.delete_generation("generation-2") + _build(index, "generation-2") + + +def test_vector_generation_rejects_invalid_vectors_before_publish(tmp_path: Path) -> None: + index = _index(tmp_path / "vectors.sqlite3") + with pytest.raises(VectorIndexError, match="normalized"): + _build(index, "bad", vectors=(("dome-performance", "dome:bad", (2.0, 0.0, 0.0)),)) + + assert index.active_generation() is None + assert ( + index.search( + query=(1.0, 0.0, 0.0), + project_ids=("dome-performance",), + include_shared=False, + limit=10, + ) + == () + ) diff --git a/tests/adapters/test_text_extractor.py b/tests/adapters/test_text_extractor.py new file mode 100644 index 0000000..35a1144 --- /dev/null +++ b/tests/adapters/test_text_extractor.py @@ -0,0 +1,117 @@ +import pytest + +from gewu.adapters.text_extractor import TextExtractionError, TextMarkdownExtractor +from gewu.domain.validation import extraction_digest + + +def test_markdown_extraction_preserves_unicode_and_original_location_maps() -> None: + source = "# Evidence\r\nCafe\u0301 has 400 visitors.\r\n" + extraction = TextMarkdownExtractor().extract( + "shared:source:r1", source.encode("utf-8"), "text/markdown" + ) + assert extraction.extraction_id == extraction_digest(extraction) + block = extraction.blocks[0] + assert "Café has 400 visitors." in block.normalized_text + normalized_start = block.normalized_text.index("Café") + normalized_end = normalized_start + len("Café") + original_spans = block.normalization_map[normalized_start:normalized_end] + original_quote = block.original_text[ + min(span[0] for span in original_spans) : max(span[1] for span in original_spans) + ] + assert original_quote == "Cafe\u0301" + + +def test_html_extraction_keeps_visible_text_and_excludes_active_content() -> None: + source = ( + "Fixture title" + "" + "

Evidence

Café has 400 visitors.

" + "" + "

Second paragraph.

" + ) + extraction = TextMarkdownExtractor().extract( + "shared:html:r1", source.encode("utf-8"), "text/html" + ) + assert extraction.parser_version == "0.3.0" + text = "\n".join(block.original_text for block in extraction.blocks) + assert "Evidence" in text + assert "Café has 400 visitors." in "\n".join( + block.normalized_text for block in extraction.blocks + ) + assert "ignoreThisSecret" not in text + assert "fallback text" not in text + assert "display:none" not in text + block = extraction.blocks[0] + normalized_start = block.normalized_text.index("Café") + original_spans = block.normalization_map[normalized_start : normalized_start + 4] + original_quote = block.original_text[ + min(span[0] for span in original_spans) : max(span[1] for span in original_spans) + ] + assert original_quote == "Cafe\u0301" + + +def test_extraction_splits_bounded_blocks_without_losing_text() -> None: + source = ("a" * 80_000 + "\n" + "b" * 200).encode("utf-8") + extraction = TextMarkdownExtractor().extract("shared:large:r1", source, "text/plain") + assert len(extraction.blocks) == 2 + assert "".join(block.original_text for block in extraction.blocks) == source.decode("utf-8") + assert all(len(block.normalized_text) <= 80_000 for block in extraction.blocks) + + +def test_webvtt_extraction_records_exact_cue_timestamps_and_text_offsets() -> None: + source = ( + "WEBVTT\n\n" + "cue-1\n00:14:32.000 --> 00:14:36.500 align:start\n" + "The audience entered the room.\n\n" + "cue-2\n00:14:40.000 --> 00:14:42.000\n" + "The host introduced the film.\n" + ) + extraction = TextMarkdownExtractor().extract( + "shared:talk-transcript:r1", source.encode(), "text/vtt" + ) + cues = [block for block in extraction.blocks if block.time_start_ms is not None] + assert [(cue.time_start_ms, cue.time_end_ms) for cue in cues] == [ + (872_000, 876_500), + (880_000, 882_000), + ] + quote = "The audience entered the room." + assert quote in cues[0].normalized_text + start = cues[0].normalized_text.index(quote) + location = cues[0].normalization_map[start : start + len(quote)] + original = cues[0].original_text[ + min(span[0] for span in location) : max(span[1] for span in location) + ] + assert original == quote + + +def test_subrip_transcript_extracts_comma_millisecond_timestamps() -> None: + extraction = TextMarkdownExtractor().extract( + "shared:interview:r1", + b"1\n00:01:02,250 --> 00:01:03,750\nA precise quote.\n", + "application/x-subrip", + ) + cue = next(block for block in extraction.blocks if block.time_start_ms is not None) + assert (cue.time_start_ms, cue.time_end_ms) == (62_250, 63_750) + assert "A precise quote." in cue.normalized_text + + +@pytest.mark.parametrize( + "content", + [ + b"WEBVTT\n\n00:00:05.000 --> 00:00:02.000\nReversed time.\n", + b"WEBVTT\n\n00:00:01.000 --> invalid\nMalformed time.\n", + b"WEBVTT\n\nNo timestamped cue.\n", + ], +) +def test_transcript_extraction_rejects_invalid_or_missing_timestamps(content: bytes) -> None: + with pytest.raises(TextExtractionError): + TextMarkdownExtractor().extract("shared:bad-transcript:r1", content, "text/vtt") + + +@pytest.mark.parametrize( + ("content", "media_type"), + [(b"\xff", "text/plain"), (b"payload", "application/pdf"), (b"", "text/plain")], +) +def test_extractor_rejects_invalid_or_unsupported_sources(content: bytes, media_type: str) -> None: + with pytest.raises(TextExtractionError): + TextMarkdownExtractor().extract("shared:source:r1", content, media_type) diff --git a/tests/adapters/test_url_fetch.py b/tests/adapters/test_url_fetch.py new file mode 100644 index 0000000..dad9813 --- /dev/null +++ b/tests/adapters/test_url_fetch.py @@ -0,0 +1,189 @@ +from __future__ import annotations + +import socket +import time +from collections import deque +from typing import Any + +import pytest + +import gewu.adapters.url_fetch as url_fetch +from gewu.adapters.url_fetch import ( + MAX_CAPTURE_BYTES, + PublicUrlFetcher, + UrlCaptureError, +) + + +class FakeResponse: + def __init__(self, status: int, headers: dict[str, str], body: bytes = b"") -> None: + self.status = status + self.headers = headers + self.body = body + self.read_calls = 0 + + def getheader(self, name: str) -> str | None: + return self.headers.get(name) + + def read(self, amount: int) -> bytes: + self.read_calls += 1 + block, self.body = self.body[:amount], self.body[amount:] + return block + + +class FakeConnection: + def __init__(self, response: FakeResponse) -> None: + self.response = response + self.request_data: tuple[str, str, dict[str, str]] | None = None + self.closed = False + + def request(self, method: str, path: str, headers: dict[str, str]) -> None: + self.request_data = (method, path, headers) + + def getresponse(self) -> FakeResponse: + return self.response + + def close(self) -> None: + self.closed = True + + +def public_resolver(host: str, port: int, **_kwargs: Any) -> list[tuple[Any, ...]]: + return [(socket.AF_INET, socket.SOCK_STREAM, 6, "", ("93.184.216.34", port))] + + +def test_capture_requires_exact_host_allowlist_and_pins_validated_dns_address() -> None: + response = FakeResponse( + 200, + {"Content-Type": "text/plain; charset=utf-8", "X-Source-Title": "Fixture title"}, + b"fixture source", + ) + connections: list[FakeConnection] = [] + pins: list[tuple[str, str, int, str, int]] = [] + + def factory(scheme: str, host: str, port: int, address: str, timeout: int) -> FakeConnection: + pins.append((scheme, host, port, address, timeout)) + connection = FakeConnection(response) + connections.append(connection) + return connection + + fetcher = PublicUrlFetcher( + {"research.example"}, resolver=public_resolver, connection_factory=factory + ) + captured = fetcher.fetch("https://research.example/reference?q=1#ignored") + + assert captured.original_url == "https://research.example/reference?q=1#ignored" + assert captured.final_url == "https://research.example/reference?q=1#ignored" + assert captured.media_type == "text/plain" + assert captured.title == "Fixture title" + assert captured.content == b"fixture source" + assert pins[0][1:4] == ("research.example", 443, "93.184.216.34") + assert connections[0].request_data == ( + "GET", + "/reference?q=1", + { + "Host": "research.example", + "User-Agent": "GEWU-source-capture/1.0", + "Accept": "application/pdf, text/html, text/plain, text/markdown", + "Accept-Encoding": "identity", + "Connection": "close", + }, + ) + assert connections[0].closed + + +def test_private_dns_result_is_rejected_before_opening_a_connection() -> None: + factory_called = False + + def resolver(host: str, port: int, **_kwargs: Any) -> list[tuple[Any, ...]]: + return [(socket.AF_INET, socket.SOCK_STREAM, 6, "", ("127.0.0.1", port))] + + def factory(*_args: Any) -> FakeConnection: + nonlocal factory_called + factory_called = True + return FakeConnection(FakeResponse(200, {"Content-Type": "text/plain"})) + + fetcher = PublicUrlFetcher({"research.example"}, resolver=resolver, connection_factory=factory) + with pytest.raises(UrlCaptureError) as error: + fetcher.fetch("http://research.example/source") + assert error.value.code == "FETCH_BLOCKED" + assert not factory_called + + +def test_dns_resolution_has_a_bounded_wait(monkeypatch: pytest.MonkeyPatch) -> None: + def slow_resolver(host: str, port: int, **_kwargs: Any) -> list[tuple[Any, ...]]: + time.sleep(0.03) + return public_resolver(host, port) + + monkeypatch.setattr(url_fetch, "DNS_TIMEOUT_SECONDS", 0.001) + fetcher = PublicUrlFetcher({"research.example"}, resolver=slow_resolver) + with pytest.raises(UrlCaptureError) as error: + fetcher.fetch("https://research.example/source") + assert error.value.code == "FETCH_TIMEOUT" + + +def test_redirect_is_revalidated_against_allowlist_before_next_connection() -> None: + first = FakeResponse(302, {"Location": "http://127.0.0.1/metadata"}) + connection_count = 0 + + def factory(*_args: Any) -> FakeConnection: + nonlocal connection_count + connection_count += 1 + return FakeConnection(first) + + fetcher = PublicUrlFetcher( + {"research.example"}, resolver=public_resolver, connection_factory=factory + ) + with pytest.raises(UrlCaptureError) as error: + fetcher.fetch("http://research.example/start") + assert error.value.code == "FETCH_BLOCKED" + assert connection_count == 1 + + +def test_capture_rejects_unsupported_types_encoding_and_oversize() -> None: + responses = deque( + [ + FakeResponse(200, {"Content-Type": "application/zip"}), + FakeResponse( + 200, + {"Content-Type": "text/plain", "Content-Encoding": "gzip"}, + ), + FakeResponse( + 200, + { + "Content-Type": "text/plain", + "Content-Length": str(MAX_CAPTURE_BYTES + 1), + }, + ), + ] + ) + + def factory(*_args: Any) -> FakeConnection: + return FakeConnection(responses.popleft()) + + fetcher = PublicUrlFetcher( + {"research.example"}, resolver=public_resolver, connection_factory=factory + ) + for expected_code in ( + "UNSUPPORTED_MEDIA_TYPE", + "UNSUPPORTED_ENCODING", + "CAPTURE_TOO_LARGE", + ): + with pytest.raises(UrlCaptureError) as error: + fetcher.fetch("http://research.example/source") + assert error.value.code == expected_code + + +@pytest.mark.parametrize( + "url", + [ + "file:///etc/passwd", + "http://user:password@research.example/source", + "http://research.example:8080/source", + "http://research.example/line\nInjected: header", + ], +) +def test_capture_rejects_unsafe_url_forms(url: str) -> None: + fetcher = PublicUrlFetcher({"research.example"}, resolver=public_resolver) + with pytest.raises(UrlCaptureError) as error: + fetcher.fetch(url) + assert error.value.code == "URL_INVALID" diff --git a/tests/api/__init__.py b/tests/api/__init__.py new file mode 100644 index 0000000..1f32217 --- /dev/null +++ b/tests/api/__init__.py @@ -0,0 +1 @@ +"""Transport boundary tests.""" diff --git a/tests/api/test_accounts.py b/tests/api/test_accounts.py new file mode 100644 index 0000000..eb8c058 --- /dev/null +++ b/tests/api/test_accounts.py @@ -0,0 +1,264 @@ +import asyncio +import sqlite3 +from pathlib import Path + +import httpx2 + +from gewu.adapters.git_repository import GitCanonicalRepository +from gewu.adapters.sqlite_identity import SQLiteIdentityStore +from gewu.application.reading import CanonicalNodeReader +from gewu.bootstrap.app import create_app + +PASSWORD = "initial-password-fixture" + + +def test_password_accounts_owned_agent_keys_and_session_lifecycle(tmp_path: Path) -> None: + async def exercise() -> None: + store = SQLiteIdentityStore(tmp_path / "identity.sqlite3") + operator = store.provision( + credential_id="operator", principal_id="operator", roles=("admin",) + ) + app = create_app( + identity_provider=store, + account_identity=store, + credential_administration=store, + node_reader=CanonicalNodeReader(GitCanonicalRepository(tmp_path / "knowledge.git")), + ) + transport = httpx2.ASGITransport(app=app) + operator_headers = {"Authorization": f"Bearer {operator}"} + async with httpx2.AsyncClient(transport=transport, base_url="https://gewu.test") as admin: + body = { + "username": "alice", + "password": PASSWORD, + "role": "editor", + "project_ids": ["pilot"], + } + assert (await admin.post("/v1/admin/users", json=body)).status_code == 401 + created = await admin.post("/v1/admin/users", json=body, headers=operator_headers) + assert created.status_code == 201 and PASSWORD not in created.text + assert ( + await admin.post("/v1/admin/users", json=body, headers=operator_headers) + ).status_code == 409 + for username, role in (("bob", "editor"), ("viewer", "viewer"), ("admin", "admin")): + assert ( + await admin.post( + "/v1/admin/users", + headers=operator_headers, + json={**body, "username": username, "role": role}, + ) + ).status_code == 201 + short_secret = "shortsecret" + invalid = await admin.post( + "/v1/admin/users", + headers=operator_headers, + json={**body, "username": "invalid", "password": short_secret}, + ) + assert invalid.status_code == 422 and short_secret not in invalid.text + listing = await admin.get("/v1/admin/users", headers=operator_headers) + assert "password_hash" not in listing.text and PASSWORD not in listing.text + + async with httpx2.AsyncClient( + transport=transport, base_url="https://gewu.test" + ) as alice: + login = await alice.post( + "/v1/users/session", + json={"username": "alice", "password": PASSWORD}, + headers={"Origin": "https://gewu.test"}, + ) + assert login.status_code == 200 + assert "secure" in login.headers["set-cookie"].lower() + csrf = {"X-GEWU-CSRF": login.json()["csrf_token"]} + assert (await alice.get("/v1/me")).json()["principal_id"] == "user:alice" + assert (await alice.get("/v1/admin/users")).status_code == 403 + assert ( + await alice.post("/v1/admin/users", headers=csrf, json=body) + ).status_code == 403 + agent_body = {"name": "codex-laptop", "project_ids": ["pilot"]} + assert ( + await alice.post("/v1/me/agent-credentials", json=agent_body) + ).status_code == 403 + assert ( + await alice.post( + "/v1/me/agent-credentials", + headers={**csrf, "Origin": "https://attacker"}, + json=agent_body, + ) + ).status_code == 403 + assert ( + await alice.post( + "/v1/me/agent-credentials", + headers=csrf, + json={**agent_body, "project_ids": ["other"]}, + ) + ).status_code == 403 + assert ( + await alice.post( + "/v1/me/agent-credentials", + headers=csrf, + json={**agent_body, "role": "admin"}, + ) + ).status_code == 422 + agent = await alice.post("/v1/me/agent-credentials", headers=csrf, json=agent_body) + assert agent.status_code == 201 + key_id, token = agent.json()["credential_id"], agent.json()["token"] + agent_principal = store.authenticate(token) + assert agent_principal and agent_principal.can_submit_project("pilot") + assert not agent_principal.can_submit_project("other") + assert (await alice.get("/v1/me/agent-credentials")).json()[0][ + "credential_id" + ] == key_id + assert token not in (await alice.get("/v1/me/agent-credentials")).text + assert ( + await alice.get("/v1/me", headers={"Authorization": f"Bearer {token}"}) + ).status_code == 403 + + async with httpx2.AsyncClient( + transport=transport, base_url="https://gewu.test" + ) as bob: + bob_login = await bob.post( + "/v1/users/session", json={"username": "bob", "password": PASSWORD} + ) + bob_csrf = {"X-GEWU-CSRF": bob_login.json()["csrf_token"]} + assert (await bob.get("/v1/me/agent-credentials")).json() == [] + assert ( + await bob.delete(f"/v1/me/agent-credentials/{key_id}", headers=bob_csrf) + ).status_code == 404 + assert store.authenticate(token) is not None + bob_agent = await bob.post( + "/v1/me/agent-credentials", headers=bob_csrf, json=agent_body + ) + rotated = store.rotate(bob_agent.json()["credential_id"], "rotated-bob-key") + assert (await bob.get("/v1/me/agent-credentials")).json()[-1][ + "owner_principal_id" + ] == "user:bob" + assert ( + await admin.delete( + "/v1/admin/credentials/account:bob", headers=operator_headers + ) + ).status_code == 200 + assert store.authenticate(rotated) is None + assert (await bob.get("/v1/me")).status_code == 401 + + password_body = { + "current_password": PASSWORD, + "new_password": "changed-password-fixture", + } + assert (await alice.post("/v1/me/password", json=password_body)).status_code == 403 + assert ( + await alice.post( + "/v1/me/password", + headers=csrf, + json={**password_body, "current_password": "wrong"}, + ) + ).status_code == 403 + second_session = store.login_account("alice", PASSWORD) + assert second_session + changed = await alice.post("/v1/me/password", headers=csrf, json=password_body) + assert changed.status_code == 200 + assert store.authenticate_browser_session(second_session.session_token) is None + assert (await alice.get("/v1/me")).status_code == 401 + assert store.login_account("alice", PASSWORD) is None + assert store.authenticate(token) is not None + relogin = await alice.post( + "/v1/users/session", + json={"username": "alice", "password": password_body["new_password"]}, + ) + csrf = {"X-GEWU-CSRF": relogin.json()["csrf_token"]} + assert ( + await alice.delete(f"/v1/me/agent-credentials/{key_id}", headers=csrf) + ).status_code == 200 + assert store.authenticate(token) is None + another = await alice.post( + "/v1/me/agent-credentials", headers=csrf, json=agent_body + ) + assert ( + await admin.delete("/v1/admin/users/alice", headers=operator_headers) + ).status_code == 200 + assert store.authenticate(another.json()["token"]) is None + assert (await alice.get("/v1/me")).status_code == 401 + assert store.login_account("alice", password_body["new_password"]) is None + + async with httpx2.AsyncClient( + transport=transport, base_url="https://gewu.test" + ) as viewer: + login = await viewer.post( + "/v1/users/session", json={"username": "viewer", "password": PASSWORD} + ) + csrf = {"X-GEWU-CSRF": login.json()["csrf_token"]} + assert ( + await viewer.post( + "/v1/me/agent-credentials", + headers=csrf, + json={"name": "agent", "project_ids": ["pilot"]}, + ) + ).status_code == 403 + async with httpx2.AsyncClient( + transport=transport, base_url="https://gewu.test" + ) as human_admin: + login = await human_admin.post( + "/v1/users/session", json={"username": "admin", "password": PASSWORD} + ) + csrf = {"X-GEWU-CSRF": login.json()["csrf_token"]} + assert ( + await human_admin.delete("/v1/admin/users/admin", headers=csrf) + ).status_code == 403 + with sqlite3.connect(store.database) as db: + assert ( + db.execute("SELECT password_hash FROM user_accounts WHERE username='bob'") + .fetchone()[0] + .startswith("$argon2id$") + ) + assert ( + PASSWORD not in (await admin.get("/v1/admin/events", headers=operator_headers)).text + ) + assert len(SQLiteIdentityStore(store.database).list_accounts(limit=50, offset=0)) == 4 + + asyncio.run(exercise()) + + +def test_password_login_origin_tls_size_and_throttle(tmp_path: Path) -> None: + async def exercise() -> None: + store = SQLiteIdentityStore(tmp_path / "identity.sqlite3") + store.create_account( + username="alice", + password=PASSWORD, + role="editor", + project_ids=("pilot",), + actor="operator", + ) + app = create_app( + identity_provider=store, + account_identity=store, + node_reader=CanonicalNodeReader(GitCanonicalRepository(tmp_path / "knowledge.git")), + ) + async with httpx2.AsyncClient( + transport=httpx2.ASGITransport(app=app), base_url="http://gewu.test" + ) as http: + body = {"username": "alice", "password": PASSWORD} + assert (await http.post("/v1/users/session", json=body)).status_code == 403 + assert ( + await http.post( + "https://gewu.test/v1/users/session", + json=body, + headers={"Origin": "https://attacker.invalid"}, + ) + ).status_code == 403 + assert ( + await http.post("https://gewu.test/v1/users/session", content=b" " * 4097) + ).status_code == 413 + for _ in range(5): + assert ( + await http.post( + "https://gewu.test/v1/users/session", json={**body, "password": "wrong"} + ) + ).status_code == 401 + assert ( + await http.post("https://gewu.test/v1/users/session", json=body) + ).status_code == 401 + with store._connect() as db: + db.execute("UPDATE account_login_failures SET failed_at=0") + assert ( + await http.post("https://gewu.test/v1/users/session", json=body) + ).status_code == 200 + + asyncio.run(exercise()) diff --git a/tests/api/test_agent_contract.py b/tests/api/test_agent_contract.py new file mode 100644 index 0000000..39c9762 --- /dev/null +++ b/tests/api/test_agent_contract.py @@ -0,0 +1,31 @@ +import json +from pathlib import Path + +from scripts.export_agent_contract import PACK, build_contract + + +def test_pack_mcp_contract_matches_the_live_server_and_generated_schemas( + tmp_path: Path, +) -> None: + snapshot_path = PACK / "contracts" / "service-v1.json" + snapshot = json.loads(snapshot_path.read_text(encoding="utf-8")) + actual = build_contract(tmp_path) + assert actual == snapshot + assert (PACK / "contracts" / "catalog-v1.json").read_bytes() == ( + Path(__file__).resolve().parents[2] / "schema" / "v1" / "catalog.json" + ).read_bytes() + + packaged_schemas = PACK / "contracts" + for schema in snapshot["record_schemas"]: + schema_path = packaged_schemas / schema["path"] + assert schema_path.is_file() + assert ( + schema_path.read_bytes() + == ( + Path(__file__).resolve().parents[2] + / "schema" + / "v1" + / "records" + / Path(schema["path"]).name + ).read_bytes() + ) diff --git a/tests/api/test_authenticated_read.py b/tests/api/test_authenticated_read.py new file mode 100644 index 0000000..3c5ac7e --- /dev/null +++ b/tests/api/test_authenticated_read.py @@ -0,0 +1,273 @@ +import asyncio +import hashlib +from pathlib import Path + +import httpx +import yaml + +from gewu.adapters.git_repository import GitCanonicalRepository +from gewu.adapters.sqlite_identity import SQLiteIdentityStore +from gewu.adapters.text_extractor import TextMarkdownExtractor +from gewu.application.reading import CanonicalNodeReader +from gewu.application.uploads import LocalArtifactStore +from gewu.bootstrap.app import create_app +from tests.domain.proposal_fixtures import NODE, SOURCE_REVISION + + +def test_authenticated_scoped_canonical_node_read(tmp_path: Path) -> None: + async def exercise() -> None: + repository = GitCanonicalRepository(tmp_path / "knowledge.git") + project_key = hashlib.sha256(NODE.project_id.encode()).hexdigest() + node_key = hashlib.sha256(NODE.node_id.encode()).hexdigest() + node_path = f"projects/{project_key}/nodes/{node_key}.yaml" + source_path = ( + f"sources/{hashlib.sha256(SOURCE_REVISION.source_id.encode()).hexdigest()}/" + f"{hashlib.sha256(SOURCE_REVISION.revision_id.encode()).hexdigest()}.yaml" + ) + artifact_store = LocalArtifactStore(tmp_path / "artifacts") + source_bytes = b"# Archived source\nA precise citation.\n" + source_digest = hashlib.sha256(source_bytes).hexdigest() + upload_id = artifact_store.begin( + filename="source.md", + media_type="text/markdown", + byte_length=len(source_bytes), + expected_sha256=source_digest, + owner_id="agent-pilot", + project_id=NODE.project_id, + ).upload_id + artifact_store.put_chunk( + upload_id, + offset=0, + content=source_bytes, + chunk_sha256=source_digest, + owner_id="agent-pilot", + ) + artifact_store.finalize(upload_id, owner_id="agent-pilot") + extraction = TextMarkdownExtractor().extract( + SOURCE_REVISION.revision_id, source_bytes, "text/markdown" + ) + artifact_store.save_extraction( + extraction, owner_id="agent-pilot", project_id=NODE.project_id + ) + archived_source = SOURCE_REVISION.model_copy( + update={"artifact_sha256": source_digest, "byte_length": len(source_bytes)} + ) + repository.publish_files( + expected_head=repository.head(), + files={node_path: yaml.safe_dump(NODE.model_dump(mode="json")).encode()}, + message="Publish test node", + index_directory=tmp_path / "publishing", + ) + updated_node = NODE.model_copy(update={"summary": "A revised accepted summary."}) + restricted_source = SOURCE_REVISION.model_copy( + update={ + "source_id": "video:private", + "revision_id": "video:private:r1", + "ingested_for": ("interactive-video",), + } + ) + restricted_source_path = ( + f"sources/{hashlib.sha256(restricted_source.source_id.encode()).hexdigest()}/" + f"{hashlib.sha256(restricted_source.revision_id.encode()).hexdigest()}.yaml" + ) + reference_source = SOURCE_REVISION.model_copy( + update={ + "source_id": "dome:reference-only", + "revision_id": "dome:reference-only:r1", + "artifact_sha256": None, + "media_type": "application/octet-stream", + "byte_length": 0, + "original_url": "https://research.example/unavailable-paper", + "ingested_for": (NODE.project_id,), + "archive_status": "reference-only", + } + ) + reference_source_path = ( + f"sources/{hashlib.sha256(reference_source.source_id.encode()).hexdigest()}/" + f"{hashlib.sha256(reference_source.revision_id.encode()).hexdigest()}.yaml" + ) + repository.publish_files( + expected_head=repository.head(), + files={ + node_path: yaml.safe_dump(updated_node.model_dump(mode="json")).encode(), + source_path: yaml.safe_dump(archived_source.model_dump(mode="json")).encode(), + restricted_source_path: yaml.safe_dump( + restricted_source.model_dump(mode="json") + ).encode(), + reference_source_path: yaml.safe_dump( + reference_source.model_dump(mode="json") + ).encode(), + }, + message="Revise node and register source", + index_directory=tmp_path / "publishing", + ) + repository.publish_files( + expected_head=repository.head(), + files={"metadata/unrelated.yaml": b"accepted but unrelated\n"}, + message="Advance repository without changing the node", + index_directory=tmp_path / "publishing", + ) + identities = SQLiteIdentityStore(tmp_path / "identity.sqlite3") + token = identities.provision( + credential_id="agent-key", + principal_id="agent-pilot", + roles=("agent",), + project_ids=(NODE.project_id,), + ) + app = create_app( + identity_provider=identities, + node_reader=CanonicalNodeReader(repository), + artifact_store=artifact_store, + ) + async with httpx.AsyncClient( + transport=httpx.ASGITransport(app=app), base_url="http://gewu.test" + ) as client: + path = f"/v1/projects/{NODE.project_id}/nodes/{NODE.node_id}" + assert (await client.get(path)).status_code == 401 + malformed_id = await client.get( + f"/v1/projects/{NODE.project_id}/nodes/not%20a%20record-id", + headers={"Authorization": f"Bearer {token}"}, + ) + assert malformed_id.status_code == 422 + denied = await client.get( + f"/v1/projects/interactive-video/nodes/{NODE.node_id}", + headers={"Authorization": f"Bearer {token}"}, + ) + assert denied.status_code == 403 + accepted = await client.get(path, headers={"Authorization": f"Bearer {token}"}) + assert accepted.status_code == 200 + envelope = accepted.json() + assert envelope["retrieval_mode"] == "canonical" + assert envelope["canonical_revision"] == repository.head() + assert envelope["data"][0]["node_id"] == NODE.node_id + assert envelope["citations"][0]["quote"] == NODE.claims[0].evidence[0].quote + history = await client.get( + f"{path}/history", headers={"Authorization": f"Bearer {token}"} + ) + assert history.status_code == 200 + history_data = history.json()["data"] + assert len(history_data) == 2 + assert history_data[0]["node"]["summary"] == updated_node.summary + latest_node_revision = history_data[0]["revision"] + assert latest_node_revision != repository.head() + latest_file_version = await client.get( + f"{path}?revision={latest_node_revision}", + headers={"Authorization": f"Bearer {token}"}, + ) + assert latest_file_version.status_code == 200 + assert latest_file_version.json()["canonical_revision"] == latest_node_revision + assert latest_file_version.json()["data"][0]["summary"] == updated_node.summary + historical_revision = history_data[-1]["revision"] + historical = await client.get( + f"{path}?revision={historical_revision}", + headers={"Authorization": f"Bearer {token}"}, + ) + assert historical.status_code == 200, historical.text + assert historical.json()["canonical_revision"] == historical_revision + assert historical.json()["data"][0]["summary"] == NODE.summary + assert historical.json()["citations"][0]["quote"] == NODE.claims[0].evidence[0].quote + malformed_revision = await client.get( + f"{path}?revision=not-a-commit", + headers={"Authorization": f"Bearer {token}"}, + ) + assert malformed_revision.status_code == 422 + unknown_revision = await client.get( + f"{path}?revision={'a' * 40}", + headers={"Authorization": f"Bearer {token}"}, + ) + assert unknown_revision.status_code == 404 + source = await client.get( + f"/v1/projects/{NODE.project_id}/sources/{SOURCE_REVISION.revision_id}", + headers={"Authorization": f"Bearer {token}"}, + ) + assert source.status_code == 200 + assert source.json()["data"][0]["revision_id"] == SOURCE_REVISION.revision_id + reference_path = ( + f"/v1/projects/{NODE.project_id}/sources/{reference_source.revision_id}" + ) + reference = await client.get( + reference_path, headers={"Authorization": f"Bearer {token}"} + ) + assert reference.status_code == 200 + assert reference.json()["data"][0]["artifact_sha256"] is None + diagnostics_path = "/v1/diagnostics?projects=dome-performance" + assert (await client.get("/v1/diagnostics")).status_code == 401 + diagnostic_response = await client.get( + diagnostics_path, headers={"Authorization": f"Bearer {token}"} + ) + assert diagnostic_response.status_code == 200 + diagnostic_body = diagnostic_response.json() + catalog_path = Path(__file__).resolve().parents[2] / "schema/v1/catalog.json" + assert ( + diagnostic_body["catalog_sha256"] + == hashlib.sha256(catalog_path.read_bytes()).hexdigest() + ) + assert diagnostic_body["selected_scope"]["projects"] == [NODE.project_id] + assert diagnostic_body["index_state"] == "not_configured" + assert diagnostic_body["max_upload_bytes"] == 50 * 1024 * 1024 + assert "get_source_artifact" in diagnostic_body["authorized_operations"] + assert token not in diagnostic_response.text + assert "principal_id" not in diagnostic_body + forbidden_diagnostic_scope = await client.get( + "/v1/diagnostics?projects=interactive-video", + headers={"Authorization": f"Bearer {token}"}, + ) + assert forbidden_diagnostic_scope.status_code == 403 + forbidden_shared_scope = await client.get( + diagnostics_path + "&include_shared=true", + headers={"Authorization": f"Bearer {token}"}, + ) + assert forbidden_shared_scope.status_code == 403 + artifact_path = ( + f"/v1/projects/{NODE.project_id}/sources/{SOURCE_REVISION.revision_id}/artifact" + ) + assert (await client.get(artifact_path)).status_code == 401 + archived = await client.get(artifact_path, headers={"Authorization": f"Bearer {token}"}) + assert archived.status_code == 200 + assert archived.content == source_bytes + assert archived.headers["etag"] == f'"{source_digest}"' + assert archived.headers["x-content-type-options"] == "nosniff" + assert archived.headers["cache-control"] == "private, no-store" + assert archived.headers["content-security-policy"] == "default-src 'none'; sandbox" + missing_artifact = await client.get( + f"{reference_path}/artifact", headers={"Authorization": f"Bearer {token}"} + ) + assert missing_artifact.status_code == 404 + block = extraction.blocks[0] + offset = block.normalized_text.index("precise") + location_path = ( + f"/v1/projects/{NODE.project_id}/sources/{SOURCE_REVISION.revision_id}" + f"/extractions/{extraction.extraction_id}/blocks/{block.block_id}" + ) + location = await client.get( + location_path, + params={"offset": offset, "length": len("precise")}, + headers={"Authorization": f"Bearer {token}"}, + ) + assert location.status_code == 200 + assert location.json()["normalized_text"] == "precise" + assert location.json()["original_text"] == "precise" + denied_location = await client.get( + location_path, + params={"offset": offset, "length": len("precise")}, + ) + assert denied_location.status_code == 401 + restricted = await client.get( + f"/v1/projects/{NODE.project_id}/sources/{restricted_source.revision_id}", + headers={"Authorization": f"Bearer {token}"}, + ) + assert restricted.status_code == 404 + restricted_artifact = await client.get( + f"/v1/projects/{NODE.project_id}/sources/{restricted_source.revision_id}/artifact", + headers={"Authorization": f"Bearer {token}"}, + ) + assert restricted_artifact.status_code == 404 + assert identities.revoke("agent-key") + assert ( + await client.get(path, headers={"Authorization": f"Bearer {token}"}) + ).status_code == 401 + assert ( + await client.get(artifact_path, headers={"Authorization": f"Bearer {token}"}) + ).status_code == 401 + + asyncio.run(exercise()) diff --git a/tests/api/test_browser_session.py b/tests/api/test_browser_session.py new file mode 100644 index 0000000..b1f9aba --- /dev/null +++ b/tests/api/test_browser_session.py @@ -0,0 +1,189 @@ +import asyncio +from pathlib import Path + +import httpx2 + +from gewu.adapters.git_repository import GitCanonicalRepository +from gewu.adapters.sqlite_identity import SQLiteIdentityStore +from gewu.adapters.sqlite_search import SQLiteSearchIndex +from gewu.application.reading import CanonicalNodeReader +from gewu.application.search import CanonicalSearchService +from gewu.bootstrap.app import create_app +from tests.domain.proposal_fixtures import PROJECTS + + +def test_browser_session_is_http_only_human_only_and_csrf_protected(tmp_path: Path) -> None: + async def exercise() -> None: + repository = GitCanonicalRepository(tmp_path / "knowledge.git") + identities = SQLiteIdentityStore(tmp_path / "identity.sqlite3") + viewer_key = identities.provision( + credential_id="browser-viewer-key", + principal_id="browser-viewer", + roles=("viewer",), + project_ids=("dome-performance",), + ) + agent_key = identities.provision( + credential_id="browser-agent-key", + principal_id="browser-agent", + roles=("agent",), + project_ids=("dome-performance",), + ) + search = CanonicalSearchService( + index=SQLiteSearchIndex(tmp_path / "search.sqlite3", repository), + project_ids_provider=lambda: PROJECTS.keys(), + canonical_head=repository.head, + ) + app = create_app( + identity_provider=identities, + node_reader=CanonicalNodeReader(repository), + search_service=search, + ) + async with httpx2.AsyncClient( + transport=httpx2.ASGITransport(app=app), + base_url="http://gewu.test", + ) as client: + agent_session = await client.post( + "/v1/session", + headers={ + "Authorization": f"Bearer {agent_key}", + "Origin": "http://gewu.test", + }, + ) + assert agent_session.status_code == 403 + + cross_origin = await client.post( + "/v1/session", + headers={ + "Authorization": f"Bearer {viewer_key}", + "Origin": "https://attacker.invalid", + }, + ) + assert cross_origin.status_code == 403 + + created = await client.post( + "/v1/session", + headers={ + "Authorization": f"Bearer {viewer_key}", + "Origin": "http://gewu.test", + }, + ) + assert created.status_code == 200, created.text + session = created.json() + cookie = created.cookies.get("gewu_session") + assert cookie is not None + assert "httponly" in created.headers["set-cookie"].lower() + assert "samesite=strict" in created.headers["set-cookie"].lower() + assert "max-age" not in created.headers["set-cookie"].lower() + + projects = await client.get("/v1/projects") + assert projects.status_code == 200 + assert projects.json() == ["dome-performance"] + denied_csrf = await client.post( + "/v1/search", + json={ + "query": "visitors", + "scope": {"projects": ["dome-performance"], "include_shared": False}, + "budget_tokens": 5000, + }, + ) + assert denied_csrf.status_code == 403 + + searched = await client.post( + "/v1/search", + headers={"X-GEWU-CSRF": session["csrf_token"]}, + json={ + "query": "visitors", + "scope": {"projects": ["dome-performance"], "include_shared": False}, + "budget_tokens": 5000, + }, + ) + assert searched.status_code == 200, searched.text + + signed_out = await client.delete( + "/v1/session", headers={"X-GEWU-CSRF": session["csrf_token"]} + ) + assert signed_out.status_code == 200 + assert (await client.get("/v1/projects")).status_code == 401 + assert identities.authenticate_browser_session(cookie) is None + + asyncio.run(exercise()) + + +def test_session_restoration_preserves_other_tabs_and_original_expiry(tmp_path: Path) -> None: + async def exercise() -> None: + identities = SQLiteIdentityStore(tmp_path / "identity.sqlite3") + token = identities.provision( + credential_id="admin", principal_id="operator", roles=("admin",) + ) + app = create_app( + identity_provider=identities, + node_reader=CanonicalNodeReader(GitCanonicalRepository(tmp_path / "knowledge.git")), + ) + async with httpx2.AsyncClient( + transport=httpx2.ASGITransport(app=app), base_url="https://gewu.test" + ) as http: + headers = {"X-GEWU-Session": "restore", "Origin": "https://gewu.test"} + assert (await http.get("/v1/session", headers=headers)).status_code == 401 + login = await http.post("/v1/session", headers={"Authorization": f"Bearer {token}"}) + original_csrf = login.json()["csrf_token"] + cookie = login.cookies.get("gewu_session") + assert cookie + assert (await http.get("/v1/session")).status_code == 403 + assert ( + await http.get( + "/v1/session", headers={**headers, "Origin": "https://attacker.invalid"} + ) + ).status_code == 403 + assert ( + await http.get("/v1/session", headers={**headers, "Sec-Fetch-Site": "cross-site"}) + ).status_code == 403 + restored = await http.get("/v1/session", headers=headers) + assert restored.status_code == 200 + assert restored.json()["expires_at"] == login.json()["expires_at"] + assert restored.json()["password_account"] is False + assert restored.headers["cache-control"] == "private, no-store" + assert "set-cookie" not in restored.headers + assert cookie not in restored.text and token not in restored.text + restored_csrf = restored.json()["csrf_token"] + assert identities.browser_csrf_valid(cookie, original_csrf) + assert identities.browser_csrf_valid(cookie, restored_csrf) + second = await http.get("/v1/session", headers=headers) + assert second.json()["csrf_token"] == restored_csrf + assert identities.browser_csrf_valid(cookie, original_csrf) + assert not identities.browser_csrf_valid(cookie, "gwc_invalid") + signed_out = await http.delete("/v1/session", headers={"X-GEWU-CSRF": restored_csrf}) + assert signed_out.status_code == 200 + http.cookies.set("gewu_session", cookie) + assert (await http.get("/v1/session", headers=headers)).status_code == 401 + + next_login = identities.create_browser_session(token) + http.cookies.clear() + http.cookies.set("gewu_session", next_login.session_token) + identities.revoke("admin") + assert (await http.get("/v1/session", headers=headers)).status_code == 401 + + asyncio.run(exercise()) + + +def test_password_session_restore_and_pre_upgrade_session_migration(tmp_path: Path) -> None: + identities = SQLiteIdentityStore(tmp_path / "identity.sqlite3") + identities.create_account( + username="admin", + password="fixture-password", + role="admin", + project_ids=(), + actor="operator", + ) + session = identities.login_account("admin", "fixture-password") + assert session + # Reconstruct the pre-upgrade schema with an issued session, then run the migration. + with identities._connect() as db: + db.execute("ALTER TABLE browser_sessions DROP COLUMN restored_csrf_digest") + reopened = SQLiteIdentityStore(identities.database) + restored = reopened.restore_browser_session(session.session_token) + assert restored and restored.expires_at == session.expires_at + assert reopened.browser_csrf_valid(session.session_token, session.csrf_token) + assert reopened.browser_csrf_valid(session.session_token, restored.csrf_token) + with reopened._connect() as db: + db.execute("UPDATE browser_sessions SET expires_at=0") + assert reopened.restore_browser_session(session.session_token) is None diff --git a/tests/api/test_credential_admin.py b/tests/api/test_credential_admin.py new file mode 100644 index 0000000..85361b2 --- /dev/null +++ b/tests/api/test_credential_admin.py @@ -0,0 +1,103 @@ +import asyncio +from pathlib import Path + +import httpx2 + +from gewu.adapters.git_repository import GitCanonicalRepository +from gewu.adapters.sqlite_identity import SQLiteIdentityStore +from gewu.application.reading import CanonicalNodeReader +from gewu.bootstrap.app import create_app + + +def test_admin_credentials_sessions_and_secret_free_audit(tmp_path: Path) -> None: + async def exercise() -> None: + store = SQLiteIdentityStore(tmp_path / "identity.sqlite3") + admin = store.provision(credential_id="admin", principal_id="operator", roles=("admin",)) + viewer = store.provision(credential_id="viewer", principal_id="reader", roles=("viewer",)) + mixed = store.provision( + credential_id="mixed", principal_id="agent", roles=("agent", "admin") + ) + app = create_app( + identity_provider=store, + credential_administration=store, + node_reader=CanonicalNodeReader(GitCanonicalRepository(tmp_path / "knowledge.git")), + ) + async with httpx2.AsyncClient( + transport=httpx2.ASGITransport(app=app), base_url="http://test" + ) as http: + assert (await http.get("/v1/admin/credentials")).status_code == 401 + for token in (viewer, mixed): + headers = {"Authorization": f"Bearer {token}"} + assert (await http.get("/v1/admin/credentials", headers=headers)).status_code == 403 + assert (await http.get("/v1/admin/events", headers=headers)).status_code == 403 + assert ( + await http.post( + "/v1/admin/credentials", + headers=headers, + json={ + "credential_id": "denied", + "principal_id": "denied", + "role": "admin", + }, + ) + ).status_code == 403 + assert ( + await http.delete("/v1/admin/credentials/viewer", headers=headers) + ).status_code == 403 + session = await http.post("/v1/session", headers={"Authorization": f"Bearer {admin}"}) + csrf = {"X-GEWU-CSRF": session.json()["csrf_token"]} + body = { + "credential_id": "new", + "principal_id": "researcher", + "role": "agent", + "project_ids": ["pilot"], + } + assert (await http.post("/v1/admin/credentials", json=body)).status_code == 403 + assert ( + await http.post( + "/v1/admin/credentials", json=body, headers={**csrf, "Origin": "https://other"} + ) + ).status_code == 403 + assert ( + await http.post("/v1/admin/credentials", headers=csrf, content=b" " * 4097) + ).status_code == 413 + assert ( + await http.post( + "/v1/admin/credentials", headers=csrf, json={**body, "project_ids": []} + ) + ).status_code == 422 + created = await http.post("/v1/admin/credentials", headers=csrf, json=body) + assert created.status_code == 201 + token = created.json()["token"] + principal = store.authenticate(token) + assert principal and principal.project_ids == frozenset({"pilot"}) + assert ( + await http.post("/v1/admin/credentials", headers=csrf, json=body) + ).status_code == 409 + listing = await http.get("/v1/admin/credentials") + assert listing.headers["cache-control"] == "private, no-store" + assert token not in listing.text and "token_digest" not in listing.text + assert ( + await http.delete("/v1/admin/credentials/admin", headers=csrf) + ).status_code == 409 + assert (await http.delete("/v1/admin/credentials/new")).status_code == 403 + assert (await http.delete("/v1/admin/credentials/new", headers=csrf)).status_code == 200 + assert store.authenticate(token) is None + assert (await http.delete("/v1/admin/credentials/new", headers=csrf)).status_code == 404 + # Revocation invalidates already-issued browser sessions as well as bearer keys. + viewer_session = store.create_browser_session(viewer) + assert ( + await http.delete("/v1/admin/credentials/viewer", headers=csrf) + ).status_code == 200 + assert store.authenticate_browser_session(viewer_session.session_token) is None + events = await http.get("/v1/admin/events") + assert [e["action"] for e in events.json()] == [ + "credential.revoked", + "credential.revoked", + "credential.created", + ] + assert token not in events.text and admin not in events.text + reopened = SQLiteIdentityStore(store.database) + assert len(reopened.list_admin_events(limit=50)) == 3 + + asyncio.run(exercise()) diff --git a/tests/api/test_health.py b/tests/api/test_health.py new file mode 100644 index 0000000..a4ce954 --- /dev/null +++ b/tests/api/test_health.py @@ -0,0 +1,82 @@ +import asyncio +from pathlib import Path + +import httpx +import pytest +from _pytest.monkeypatch import MonkeyPatch +from fastapi.routing import APIRoute + +from gewu.bootstrap.app import create_app, create_local_app + + +def test_health_is_available_without_exposing_mutation_routes() -> None: + async def request_health() -> httpx.Response: + async with httpx.AsyncClient( + transport=httpx.ASGITransport(app=create_app()), + base_url="http://gewu.test", + ) as client: + return await client.get("/health") + + response = asyncio.run(request_health()) + assert response.status_code == 200 + assert response.json() == {"status": "ok", "service": "gewu"} + routes = { + f"{method} {route.path}" + for route in create_app().routes + if isinstance(route, APIRoute) + for method in route.methods or () + } + assert routes == {"GET /health"} + + +def test_local_factory_composes_private_persistent_read_service( + monkeypatch: MonkeyPatch, tmp_path: Path +) -> None: + data_root = tmp_path / "private-gewu" + monkeypatch.setenv("GEWU_DATA_ROOT", str(data_root)) + app = create_local_app() + route_paths = {route.path for route in app.routes if isinstance(route, APIRoute)} + assert route_paths == { + "/health", + "/v1/diagnostics", + "/v1/admin/projects", + "/v1/admin/credentials", + "/v1/admin/credentials/{credential_id}", + "/v1/admin/events", + "/v1/admin/users", + "/v1/admin/users/{username}", + "/v1/users/session", + "/v1/me", + "/v1/me/password", + "/v1/me/agent-credentials", + "/v1/me/agent-credentials/{credential_id}", + "/v1/admin/projects/{project_id}/archive", + "/v1/projects/{project_id}/nodes/{node_id}", + "/v1/projects/{project_id}/nodes/{node_id}/history", + "/v1/projects/{project_id}/sources/{revision_id}", + "/v1/projects/{project_id}/sources/{revision_id}/artifact", + "/v1/projects/{project_id}/sources/{revision_id}/extractions/{extraction_id}/blocks/{block_id}", + "/v1/neighbors", + "/v1/projects", + "/v1/search", + "/v1/sources", + "/v1/session", + "/auth/owner-session", + } + assert (data_root / "knowledge.git" / "HEAD").is_file() + assert (data_root / "identity.sqlite3").is_file() + assert (data_root / "search.sqlite3").is_file() + assert (data_root / "search.sqlite3").stat().st_mode & 0o077 == 0 + assert (data_root.stat().st_mode & 0o777) == 0o700 + + +def test_local_factory_rejects_symlinked_data_root( + monkeypatch: MonkeyPatch, tmp_path: Path +) -> None: + target = tmp_path / "real-root" + target.mkdir() + link = tmp_path / "linked-root" + link.symlink_to(target, target_is_directory=True) + monkeypatch.setenv("GEWU_DATA_ROOT", str(link)) + with pytest.raises(ValueError, match="symbolic links"): + create_local_app() diff --git a/tests/api/test_mcp_contribution.py b/tests/api/test_mcp_contribution.py new file mode 100644 index 0000000..47749c4 --- /dev/null +++ b/tests/api/test_mcp_contribution.py @@ -0,0 +1,951 @@ +import asyncio +import base64 +import hashlib +import json +import socket +from collections.abc import AsyncIterator +from contextlib import asynccontextmanager +from pathlib import Path +from typing import Any + +import httpx2 +from mcp import Client +from mcp.client.streamable_http import streamable_http_client +from mcp.types import TextContent + +from gewu.adapters.git_repository import GitCanonicalRepository +from gewu.adapters.source_extractor import LocalSourceExtractor +from gewu.adapters.sqlite_identity import SQLiteIdentityStore +from gewu.adapters.url_fetch import PublicUrlFetcher +from gewu.application.acceptance import AcceptanceService +from gewu.application.context import ValidationContext +from gewu.application.reading import CanonicalNodeReader +from gewu.application.uploads import LocalArtifactStore +from gewu.bootstrap.app import create_app +from gewu.domain.contracts import ProposalOperation, ProposalRevision, Scope, SourceRevisionRecord +from gewu.domain.validation import evidence_digest, proposal_payload_digest +from gewu.transports.stdio_connector import SelectedFile, create_connector_server +from tests.domain.proposal_fixtures import ( + BLOCK_TEXT, + NODE, + POLICIES, + PROJECTS, + SOURCE_REVISION, + proposal, +) + + +class _FixtureResponse: + def __init__(self, media_type: str, content: bytes, status: int = 200) -> None: + self.media_type = media_type + self.content = content + self.status = status + self.offset = 0 + + def getheader(self, name: str) -> str | None: + return { + "Content-Type": f"{self.media_type}; charset=utf-8", + "X-Source-Title": "Allowlisted fixture", + }.get(name) + + def read(self, amount: int) -> bytes: + result = self.content[self.offset : self.offset + amount] + self.offset += len(result) + return result + + +class _FixtureConnection: + def __init__(self) -> None: + self.closed = False + self.path = "" + + def request(self, _method: str, path: str, headers: dict[str, str]) -> None: + self.path = path + assert headers["Accept-Encoding"] == "identity" + return None + + def getresponse(self) -> _FixtureResponse: + if self.path.startswith("/missing"): + return _FixtureResponse("text/plain", b"unavailable", status=404) + if self.path.startswith("/source.md?rev=2"): + return _FixtureResponse("text/markdown", b"# Captured fixture\nRevised evidence.\n") + if self.path.startswith("/page.html"): + return _FixtureResponse( + "text/html", + b"

Web evidence

Exact HTML statement.

" + b"
", + ) + if self.path.startswith("/paper.pdf"): + return _FixtureResponse("application/pdf", _pdf_fixture()) + if self.path.startswith("/malformed.pdf"): + return _FixtureResponse("application/pdf", b"%PDF-1.4\nnot a valid PDF") + return _FixtureResponse( + "text/markdown", b"# Captured fixture\nAn exact source statement.\n" + ) + + def close(self) -> None: + self.closed = True + + +def _fixture_fetcher() -> PublicUrlFetcher: + def resolver(host: str, port: int, **_kwargs: Any) -> list[tuple[Any, ...]]: + return [(socket.AF_INET, socket.SOCK_STREAM, 6, "", ("93.184.216.34", port))] + + return PublicUrlFetcher( + {"research.example"}, + resolver=resolver, + connection_factory=lambda *_args: _FixtureConnection(), + ) + + +def _text_payload(result: Any) -> str: + assert result.content and isinstance(result.content[0], TextContent) + return result.content[0].text + + +def _pdf_fixture() -> bytes: + stream = b"BT /F1 12 Tf 72 720 Td (Archived PDF evidence.) Tj ET" + objects = [ + b"<< /Type /Catalog /Pages 2 0 R >>", + b"<< /Type /Pages /Kids [4 0 R] /Count 1 >>", + b"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>", + b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] " + b"/Resources << /Font << /F1 3 0 R >> >> /Contents 5 0 R >>", + b"<< /Length " + str(len(stream)).encode() + b" >>\nstream\n" + stream + b"\nendstream", + ] + result = bytearray(b"%PDF-1.4\n") + offsets = [0] + for number, item in enumerate(objects, start=1): + offsets.append(len(result)) + result.extend(f"{number} 0 obj\n".encode() + item + b"\nendobj\n") + xref = len(result) + result.extend(f"xref\n0 {len(offsets)}\n0000000000 65535 f \n".encode()) + for offset in offsets[1:]: + result.extend(f"{offset:010d} 00000 n \n".encode()) + result.extend( + f"trailer\n<< /Size {len(offsets)} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n".encode() + ) + return bytes(result) + + +def test_mcp_source_to_citation_vertical_slice(tmp_path: Path) -> None: + async def exercise() -> None: + repository = GitCanonicalRepository(tmp_path / "knowledge.git") + artifacts = LocalArtifactStore(tmp_path) + extractor = LocalSourceExtractor() + context = ValidationContext( + projects=PROJECTS, + policies=POLICIES, + sources={}, + extractions={}, + existing_nodes={}, + ) + identities = SQLiteIdentityStore( + tmp_path / "identity.sqlite3", + authority_lock_path=tmp_path / "workflow/locks/canonical-writer.lock", + ) + token = identities.provision( + credential_id="pilot-agent-key", + principal_id="agent-pilot", + roles=("agent",), + project_ids=("dome-performance", "shared"), + ) + viewer_token = identities.provision( + credential_id="pilot-viewer-key", + principal_id="pilot-viewer", + roles=("viewer",), + project_ids=("dome-performance", "shared"), + ) + acceptance = AcceptanceService( + repository=repository, + artifact_store=artifacts, + workflow_root=tmp_path / "workflow", + context_provider=lambda: context, + actor_is_current=lambda actor: identities.is_current( + actor.principal_id, + actor.credential_id, + roles=actor.roles, + project_ids=actor.project_ids, + ), + ) + app = create_app( + identity_provider=identities, + node_reader=CanonicalNodeReader(repository), + artifact_store=artifacts, + extractor=extractor, + acceptance_service=acceptance, + context_provider=lambda: context, + url_fetcher=_fixture_fetcher(), + ) + async with app.router.lifespan_context(app): + async with httpx2.AsyncClient( + transport=httpx2.ASGITransport(app=app), + base_url="http://localhost", + headers={"Authorization": f"Bearer {token}"}, + ) as http: + async with Client( + streamable_http_client("http://localhost/mcp", http_client=http) + ) as mcp: + tools = await mcp.list_tools() + assert {tool.name for tool in tools.tools} == { + "diagnostics", + "get_node", + "node_history", + "get_project_context", + "get_source", + "source_upload_begin", + "source_capture_text", + "source_capture_url", + "source_upload_status", + "source_upload_chunk", + "source_upload_finalize", + "source_extract", + "source_extraction_block", + "proposal_evaluate", + "proposal_submit", + "proposal_status", + "proposal_withdraw", + "run_create", + "run_checkpoint", + "run_status", + } + project_context = await mcp.call_tool( + "get_project_context", {"project_id": "dome-performance"} + ) + assert not project_context.is_error + lens_result = json.loads(_text_payload(project_context)) + assert ( + lens_result["lens"]["north_star"] == PROJECTS["dome-performance"].north_star + ) + assert lens_result["lens"]["key_questions"] == list( + PROJECTS["dome-performance"].key_questions + ) + assert "autonomy_policy_id" not in lens_result["lens"] + denied_lens = await mcp.call_tool( + "get_project_context", {"project_id": "interactive-video"} + ) + assert denied_lens.is_error + async with httpx2.AsyncClient( + transport=httpx2.ASGITransport(app=app), + base_url="http://localhost", + headers={"Authorization": f"Bearer {viewer_token}"}, + ) as viewer_http: + async with Client( + streamable_http_client("http://localhost/mcp", http_client=viewer_http) + ) as viewer_mcp: + viewer_proposal = await viewer_mcp.call_tool( + "proposal_submit", + {"proposal_json": proposal().model_dump_json()}, + ) + assert viewer_proposal.is_error + viewer_upload = await viewer_mcp.call_tool( + "source_upload_begin", + { + "project_id": "dome-performance", + "filename": "viewer.md", + "media_type": "text/markdown", + "byte_length": 4, + "expected_sha256": hashlib.sha256(b"data").hexdigest(), + }, + ) + assert viewer_upload.is_error + viewer_paste = await viewer_mcp.call_tool( + "source_capture_text", + { + "capture_id": "viewer-capture-1", + "project_id": "dome-performance", + "source_key": "viewer-paste", + "text": "Viewer supplied text", + }, + ) + assert viewer_paste.is_error + diagnostics_result = await mcp.call_tool( + "diagnostics", + {"projects": ["dome-performance"], "include_shared": True}, + ) + assert not diagnostics_result.is_error + diagnostics = json.loads(_text_payload(diagnostics_result)) + assert diagnostics["workflow_status"] == "not_verified" + assert diagnostics["selected_scope"]["include_shared"] is True + assert "proposal_submit" in diagnostics["authorized_operations"] + assert "run_checkpoint" in diagnostics["authorized_operations"] + assert token not in _text_payload(diagnostics_result) + run_request = { + "run_id": "mcp-run-pilot", + "objective": "Compare audience response", + "scope": { + "projects": ["dome-performance"], + "include_shared": False, + "all_projects": False, + }, + "lens": "Audience behavior", + "pack_id": "gewu", + "pack_version": "0.3.0", + "pack_snapshot_digest": "a" * 64, + "module_pins": [ + { + "module_id": "profile", + "implementation_version": "1.0.0", + "sha256": "b" * 64, + } + ], + } + created_run = await mcp.call_tool( + "run_create", {"request_json": json.dumps(run_request)} + ) + assert not created_run.is_error + run_record = json.loads(_text_payload(created_run)) + assert run_record["revision"] == 0 + checkpoint = { + "run_id": "mcp-run-pilot", + "expected_revision": 0, + "stage": "source-review", + } + saved_run = await mcp.call_tool( + "run_checkpoint", {"request_json": json.dumps(checkpoint)} + ) + assert not saved_run.is_error + assert json.loads(_text_payload(saved_run))["revision"] == 1 + read_run = await mcp.call_tool("run_status", {"run_id": "mcp-run-pilot"}) + assert not read_run.is_error + assert json.loads(_text_payload(read_run))["revision"] == 1 + denied = await mcp.call_tool( + "source_upload_begin", + { + "project_id": "interactive-video", + "filename": "private.md", + "media_type": "text/markdown", + "byte_length": 4, + "expected_sha256": "0" * 64, + }, + ) + assert denied.is_error + forbidden_capture = await mcp.call_tool( + "source_capture_url", + { + "project_id": "interactive-video", + "url": "https://research.example/source.md", + }, + ) + assert forbidden_capture.is_error + forbidden_paste = await mcp.call_tool( + "source_capture_text", + { + "capture_id": "out-of-scope-paste-1", + "project_id": "interactive-video", + "source_key": "out-of-scope-paste", + "text": "Must not be uploaded", + }, + ) + assert forbidden_paste.is_error + unallowlisted_capture = await mcp.call_tool( + "source_capture_url", + { + "project_id": "dome-performance", + "url": "https://internal.example/private", + }, + ) + assert unallowlisted_capture.is_error + captured = await mcp.call_tool( + "source_capture_url", + { + "project_id": "dome-performance", + "url": "https://research.example/source.md?token=do-not-echo", + }, + ) + assert not captured.is_error + capture_result = json.loads(_text_payload(captured)) + assert capture_result["title"] == "Allowlisted fixture" + assert capture_result["media_type"] == "text/markdown" + assert "do-not-echo" not in _text_payload(captured) + assert capture_result["original_url"] == ("https://research.example/source.md") + captured_source = SourceRevisionRecord.model_validate_json( + json.dumps(capture_result["source_candidate"]), strict=True + ) + assert captured_source.archive_status == "archived" + assert captured_source.final_url == "https://research.example/source.md" + assert captured_source.artifact_sha256 == capture_result["sha256"] + assert captured_source.capture_method == "url-fetch" + assert f":r:{captured_source.artifact_sha256}:" in captured_source.revision_id + pasted_text = "## Meeting note\nPasted evidence: 400 visitors.\n" + pasted_arguments = { + "capture_id": "interview-paste-1", + "project_id": "dome-performance", + "source_key": "research-interview-2026-10-14", + "text": pasted_text, + "title": "Research interview notes", + "original_url": "https://research.example/interview?token=secret", + "media_type": "text/markdown", + "source_type": "internal", + } + pasted = await mcp.call_tool("source_capture_text", pasted_arguments) + assert not pasted.is_error + pasted_retry = await mcp.call_tool("source_capture_text", pasted_arguments) + assert not pasted_retry.is_error + assert json.loads(_text_payload(pasted_retry)) == json.loads( + _text_payload(pasted) + ) + changed_paste_retry = await mcp.call_tool( + "source_capture_text", + {**pasted_arguments, "text": pasted_text + "changed"}, + ) + assert changed_paste_retry.is_error + invalid_capture_id = await mcp.call_tool( + "source_capture_text", + {**pasted_arguments, "capture_id": "invalid capture id"}, + ) + assert invalid_capture_id.is_error + pasted_result = json.loads(_text_payload(pasted)) + pasted_source = SourceRevisionRecord.model_validate_json( + json.dumps(pasted_result["source_candidate"]), strict=True + ) + assert pasted_source.capture_method == "pasted-text" + assert pasted_source.original_url == "https://research.example/interview" + assert "secret" not in _text_payload(pasted) + capture_journal = tmp_path / "staging" / "capture-receipts" + assert all( + pasted_text.encode() not in path.read_bytes() + for path in capture_journal.glob("*.json") + ) + assert ( + pasted_source.artifact_sha256 + == hashlib.sha256(pasted_text.encode()).hexdigest() + ) + assert pasted_result["byte_length"] == len(pasted_text.encode()) + assert pasted_result["extraction_id"] + pasted_extraction = artifacts.get_extraction( + pasted_result["extraction_id"], + owner_id="agent-pilot", + project_id="dome-performance", + ) + assert pasted_extraction is not None + assert pasted_extraction.source_revision_id == pasted_source.revision_id + assert ( + "Pasted evidence: 400 visitors." + in pasted_extraction.blocks[0].original_text + ) + oversized_paste = await mcp.call_tool( + "source_capture_text", + { + "capture_id": "oversized-paste-1", + "project_id": "dome-performance", + "source_key": "oversized-paste", + "text": "x" * 1_000_001, + }, + ) + assert oversized_paste.is_error + control_character_paste = await mcp.call_tool( + "source_capture_text", + { + "capture_id": "invalid-paste-1", + "project_id": "dome-performance", + "source_key": "invalid-paste", + "text": "unsafe\x00content", + }, + ) + assert control_character_paste.is_error + oversized_author_paste = await mcp.call_tool( + "source_capture_text", + { + "capture_id": "invalid-author-paste-1", + "project_id": "dome-performance", + "source_key": "invalid-author-paste", + "text": "valid body", + "authors": ["a" * 301], + }, + ) + assert oversized_author_paste.is_error + changed_capture = await mcp.call_tool( + "source_capture_url", + { + "project_id": "dome-performance", + "url": "https://research.example/source.md?rev=2", + }, + ) + assert not changed_capture.is_error + changed_source = SourceRevisionRecord.model_validate_json( + json.dumps(json.loads(_text_payload(changed_capture))["source_candidate"]), + strict=True, + ) + assert changed_source.source_id == captured_source.source_id + assert changed_source.revision_id != captured_source.revision_id + with artifacts.open_artifact( + capture_result["sha256"], project_id="dome-performance" + ) as artifact: + assert artifact.read() == ( + b"# Captured fixture\nAn exact source statement.\n" + ) + failed_capture = await mcp.call_tool( + "source_capture_url", + { + "project_id": "dome-performance", + "url": "https://research.example/missing?access-token=must-not-leak", + "title": "Temporarily unavailable paper", + "source_type": "paper", + }, + ) + assert not failed_capture.is_error + failed_result = json.loads(_text_payload(failed_capture)) + failed_source = SourceRevisionRecord.model_validate_json( + json.dumps(failed_result["source_candidate"]), strict=True + ) + assert failed_result["status"] == "capture_failed" + assert failed_source.archive_status == "capture-failed" + assert failed_source.artifact_sha256 is None + assert "access-token" not in _text_payload(failed_capture) + failed_proposal = ProposalRevision( + proposal_id="proposal-failed-url-capture", + revision=1, + initiating_project_id="dome-performance", + scope=Scope(projects=("dome-performance",), include_shared=True), + base_commit=repository.head(), + payload_digest="0" * 64, + proposer_id="agent-pilot", + run_id="run-failed-url-capture", + operations=( + ProposalOperation(operation="register_source", source=failed_source), + ), + ) + failed_proposal = failed_proposal.model_copy( + update={"payload_digest": proposal_payload_digest(failed_proposal)} + ) + registered_failure = await mcp.call_tool( + "proposal_submit", + {"proposal_json": failed_proposal.model_dump_json()}, + ) + assert not registered_failure.is_error + assert ( + json.loads(_text_payload(registered_failure))["receipt"]["state"] + == "accepted" + ) + captured_html = await mcp.call_tool( + "source_capture_url", + { + "project_id": "dome-performance", + "url": "https://research.example/page.html", + }, + ) + assert not captured_html.is_error + html_result = json.loads(_text_payload(captured_html)) + assert html_result["media_type"] == "text/html" + html_extraction_result = await mcp.call_tool( + "source_extract", + { + "project_id": "dome-performance", + "artifact_sha256": html_result["sha256"], + "source_revision_id": "shared:web-page:r1", + }, + ) + assert not html_extraction_result.is_error + html_extraction = json.loads(_text_payload(html_extraction_result)) + html_block_result = await mcp.call_tool( + "source_extraction_block", + { + "project_id": "dome-performance", + "extraction_id": html_extraction["extraction_id"], + "block_id": html_extraction["blocks"][0]["block_id"], + "offset": 0, + "length": 500, + }, + ) + assert not html_block_result.is_error + html_block = _text_payload(html_block_result) + assert "Exact HTML statement." in html_block + assert "ignoreInjection" not in html_block + captured_pdf = await mcp.call_tool( + "source_capture_url", + { + "project_id": "dome-performance", + "url": "https://research.example/paper.pdf", + }, + ) + assert not captured_pdf.is_error + pdf_result = json.loads(_text_payload(captured_pdf)) + assert pdf_result["media_type"] == "application/pdf" + with artifacts.open_artifact( + pdf_result["sha256"], project_id="dome-performance" + ) as artifact: + assert artifact.read() == _pdf_fixture() + pdf_extracted = await mcp.call_tool( + "source_extract", + { + "project_id": "dome-performance", + "artifact_sha256": pdf_result["sha256"], + "source_revision_id": "shared:pdf:r1", + }, + ) + assert not pdf_extracted.is_error + pdf_record = json.loads(_text_payload(pdf_extracted)) + assert pdf_record["blocks"][0]["page"] == 1 + pdf_block_result = await mcp.call_tool( + "source_extraction_block", + { + "project_id": "dome-performance", + "extraction_id": pdf_record["extraction_id"], + "block_id": pdf_record["blocks"][0]["block_id"], + "offset": 0, + "length": 500, + }, + ) + assert not pdf_block_result.is_error + pdf_block = json.loads(_text_payload(pdf_block_result)) + assert pdf_block["page"] == 1 + assert "Archived PDF evidence." in pdf_block["normalized_text"] + malformed_pdf = await mcp.call_tool( + "source_capture_url", + { + "project_id": "dome-performance", + "url": "https://research.example/malformed.pdf", + }, + ) + assert not malformed_pdf.is_error + malformed_result = json.loads(_text_payload(malformed_pdf)) + malformed_extraction = await mcp.call_tool( + "source_extract", + { + "project_id": "dome-performance", + "artifact_sha256": malformed_result["sha256"], + "source_revision_id": malformed_result["source_candidate"][ + "revision_id" + ], + }, + ) + assert not malformed_extraction.is_error + unavailable = json.loads(_text_payload(malformed_extraction)) + assert unavailable["status"] == "unavailable" + assert unavailable["error_code"] == "PDF_UNSUPPORTED" + content = BLOCK_TEXT.encode("utf-8") + digest = hashlib.sha256(content).hexdigest() + interrupted_begin = await mcp.call_tool( + "source_upload_begin", + { + "project_id": "dome-performance", + "filename": "interrupted.md", + "media_type": "text/markdown", + "byte_length": len(content), + "expected_sha256": digest, + }, + ) + assert not interrupted_begin.is_error + interrupted_upload = json.loads(_text_payload(interrupted_begin)) + interrupted_finalize = await mcp.call_tool( + "source_upload_finalize", + {"upload_id": interrupted_upload["upload_id"]}, + ) + assert interrupted_finalize.is_error + uncitable = await mcp.call_tool( + "source_extract", + { + "project_id": "dome-performance", + "artifact_sha256": digest, + "source_revision_id": SOURCE_REVISION.revision_id, + }, + ) + assert uncitable.is_error + + begun = await mcp.call_tool( + "source_upload_begin", + { + "project_id": "dome-performance", + "filename": "pilot.md", + "media_type": "text/markdown", + "byte_length": len(content), + "expected_sha256": digest, + }, + ) + assert not begun.is_error + upload = json.loads(_text_payload(begun)) + wrong_digest_chunk = await mcp.call_tool( + "source_upload_chunk", + { + "upload_id": upload["upload_id"], + "offset": 0, + "content_base64": base64.b64encode(content).decode(), + "chunk_sha256": "0" * 64, + }, + ) + assert wrong_digest_chunk.is_error + chunked = await mcp.call_tool( + "source_upload_chunk", + { + "upload_id": upload["upload_id"], + "offset": 0, + "content_base64": base64.b64encode(content).decode(), + "chunk_sha256": digest, + }, + ) + assert not chunked.is_error + finalized = await mcp.call_tool( + "source_upload_finalize", {"upload_id": upload["upload_id"]} + ) + assert not finalized.is_error + artifact = json.loads(_text_payload(finalized)) + extracted = await mcp.call_tool( + "source_extract", + { + "project_id": "dome-performance", + "artifact_sha256": artifact["sha256"], + "source_revision_id": SOURCE_REVISION.revision_id, + }, + ) + assert not extracted.is_error + extraction_info = json.loads(_text_payload(extracted)) + record = extractor.extract( + SOURCE_REVISION.revision_id, content, "text/markdown" + ) + assert extraction_info["extraction_id"] == record.extraction_id + + evidence = ( + NODE.claims[0] + .evidence[0] + .model_copy( + update={ + "extraction_id": record.extraction_id, + "locator": NODE.claims[0] + .evidence[0] + .locator.model_copy(update={"block_id": record.blocks[0].block_id}), + } + ) + ) + claim = NODE.claims[0].model_copy( + update={ + "evidence": (evidence,), + "assessment": NODE.claims[0].assessment.model_copy( + update={"evidence_digests": (evidence_digest(evidence),)} + ), + } + ) + node = NODE.model_copy(update={"claims": (claim,)}) + source = SOURCE_REVISION.model_copy( + update={"artifact_sha256": artifact["sha256"], "byte_length": len(content)} + ) + candidate = proposal(node=node, source=source, base_commit=repository.head()) + candidate = candidate.model_copy(update={"payload_digest": "0" * 64}) + candidate = candidate.model_copy( + update={"payload_digest": proposal_payload_digest(candidate)} + ) + + wrong_location_evidence = evidence.model_copy( + update={ + "quote": "400 visitors", + "locator": evidence.locator.model_copy(update={"start": 0, "end": 13}), + } + ) + wrong_location_claim = claim.model_copy( + update={ + "evidence": (wrong_location_evidence,), + "assessment": claim.assessment.model_copy( + update={ + "evidence_digests": (evidence_digest(wrong_location_evidence),) + } + ), + } + ) + wrong_location_node = node.model_copy( + update={"claims": (wrong_location_claim,)} + ) + wrong_location = proposal( + node=wrong_location_node, + source=source, + base_commit=repository.head(), + ) + wrong_location = wrong_location.model_copy( + update={ + "proposal_id": "proposal-wrong-location", + "payload_digest": "0" * 64, + } + ) + wrong_location = wrong_location.model_copy( + update={"payload_digest": proposal_payload_digest(wrong_location)} + ) + head_before_evaluate = repository.head() + invalid_evaluation = await mcp.call_tool( + "proposal_evaluate", + {"proposal_json": wrong_location.model_dump_json()}, + ) + assert not invalid_evaluation.is_error, invalid_evaluation.content + invalid_evaluation_payload = json.loads(_text_payload(invalid_evaluation)) + assert invalid_evaluation_payload["status"] == "invalid" + assert "EVIDENCE_MISMATCH" in { + finding["code"] for finding in invalid_evaluation_payload["findings"] + } + assert repository.head() == head_before_evaluate + rejected = await mcp.call_tool( + "proposal_submit", + {"proposal_json": wrong_location.model_dump_json()}, + ) + assert not rejected.is_error, rejected.content + rejected_outcome = json.loads(_text_payload(rejected)) + assert rejected_outcome["status"] == "invalid" + assert "EVIDENCE_MISMATCH" in { + finding["code"] for finding in rejected_outcome["findings"] + } + rejected_status = await mcp.call_tool( + "proposal_status", + {"proposal_id": "proposal-wrong-location", "revision": 1}, + ) + assert not rejected_status.is_error, rejected_status.content + rejected_status_payload = json.loads(_text_payload(rejected_status)) + assert rejected_status_payload["lifecycle"] == "evaluated" + assert rejected_status_payload["validation_status"] == "invalid" + assert rejected_status_payload["receipt"] is None + assert "quote" not in _text_payload(rejected_status) + withdrawn = await mcp.call_tool( + "proposal_withdraw", + {"proposal_id": "proposal-wrong-location", "revision": 1}, + ) + assert not withdrawn.is_error, withdrawn.content + assert json.loads(_text_payload(withdrawn))["lifecycle"] == "withdrawn" + resubmitted = await mcp.call_tool( + "proposal_submit", + {"proposal_json": wrong_location.model_dump_json()}, + ) + assert resubmitted.is_error + + candidate_head = repository.head() + valid_evaluation = await mcp.call_tool( + "proposal_evaluate", + {"proposal_json": candidate.model_dump_json()}, + ) + assert not valid_evaluation.is_error, valid_evaluation.content + valid_evaluation_payload = json.loads(_text_payload(valid_evaluation)) + assert valid_evaluation_payload["status"] == "eligible" + assert valid_evaluation_payload["eligible_for_auto_accept"] + assert repository.head() == candidate_head + spoofed_assessment = claim.assessment.model_copy( + update={"assessor_id": "agent-other"} + ) + spoofed_claim = claim.model_copy(update={"assessment": spoofed_assessment}) + spoofed_node = node.model_copy(update={"claims": (spoofed_claim,)}) + spoofed = proposal( + node=spoofed_node, + source=source, + base_commit=repository.head(), + ) + spoofed = spoofed.model_copy(update={"payload_digest": "0" * 64}) + spoofed = spoofed.model_copy( + update={"payload_digest": proposal_payload_digest(spoofed)} + ) + spoofed_result = await mcp.call_tool( + "proposal_evaluate", + {"proposal_json": spoofed.model_dump_json()}, + ) + assert not spoofed_result.is_error, spoofed_result.content + assert "ASSESSOR_ATTRIBUTION_MISMATCH" in { + finding["code"] + for finding in json.loads(_text_payload(spoofed_result))["findings"] + } + unstored = await mcp.call_tool( + "proposal_status", + {"proposal_id": candidate.proposal_id, "revision": candidate.revision}, + ) + assert unstored.is_error + + submitted = await mcp.call_tool( + "proposal_submit", + {"proposal_json": candidate.model_dump_json()}, + ) + assert not submitted.is_error, submitted.content + outcome = json.loads(_text_payload(submitted)) + assert outcome["status"] == "accepted" + assert outcome["validation_status"] == "eligible" + assert outcome["receipt"] is not None + accepted_head = repository.head() + retried = await mcp.call_tool( + "proposal_submit", + {"proposal_json": candidate.model_dump_json()}, + ) + assert not retried.is_error, retried.content + retry_outcome = json.loads(_text_payload(retried)) + assert retry_outcome["receipt"] == outcome["receipt"] + assert repository.head() == accepted_head + accepted_status = await mcp.call_tool( + "proposal_status", + {"proposal_id": candidate.proposal_id, "revision": candidate.revision}, + ) + assert not accepted_status.is_error, accepted_status.content + accepted_status_payload = json.loads(_text_payload(accepted_status)) + assert accepted_status_payload["lifecycle"] == "accepted" + assert accepted_status_payload["receipt"] == outcome["receipt"] + other_token = identities.provision( + credential_id="other-agent-key", + principal_id="agent-other", + roles=("agent",), + project_ids=("dome-performance",), + ) + async with httpx2.AsyncClient( + transport=httpx2.ASGITransport(app=app), + base_url="http://localhost", + headers={"Authorization": f"Bearer {other_token}"}, + ) as other_http: + async with Client( + streamable_http_client("http://localhost/mcp", http_client=other_http) + ) as other_mcp: + private_status = await other_mcp.call_tool( + "proposal_status", + { + "proposal_id": candidate.proposal_id, + "revision": candidate.revision, + }, + ) + assert private_status.is_error + impersonated_evaluation = await other_mcp.call_tool( + "proposal_evaluate", + {"proposal_json": candidate.model_dump_json()}, + ) + assert impersonated_evaluation.is_error + unauthorized_withdrawal = await other_mcp.call_tool( + "proposal_withdraw", + { + "proposal_id": candidate.proposal_id, + "revision": candidate.revision, + }, + ) + assert unauthorized_withdrawal.is_error + read = await mcp.call_tool( + "get_node", + {"project_id": node.project_id, "node_id": node.node_id}, + ) + assert not read.is_error + envelope = json.loads(_text_payload(read)) + assert envelope["canonical_revision"] == outcome["receipt"]["commit_id"] + assert envelope["citations"][0]["quote"] == BLOCK_TEXT + + selected_path = tmp_path / "operator-selected.md" + selected_bytes = b"A separately selected source for the stdio bridge.\n" + selected_path.write_bytes(selected_bytes) + + @asynccontextmanager + async def remote_client() -> AsyncIterator[Client]: + async with Client( + streamable_http_client("http://localhost/mcp", http_client=http) + ) as remote: + yield remote + + connector = create_connector_server( + (SelectedFile("source-1", selected_path),), remote_client + ) + async with Client(connector) as local: + connector_tools = await local.list_tools() + assert {tool.name for tool in connector_tools.tools} == { + "list_selected_files", + "upload_selected_file", + } + connector_result = await local.call_tool( + "upload_selected_file", + { + "file_id": "source-1", + "project_id": "dome-performance", + "source_revision_id": "dome:connector-probe:r1", + }, + ) + assert not connector_result.is_error + connector_text = _text_payload(connector_result) + connector_receipt = json.loads(connector_text) + assert ( + connector_receipt["sha256"] + == hashlib.sha256(selected_bytes).hexdigest() + ) + assert selected_bytes.decode() not in connector_text + + asyncio.run(exercise()) diff --git a/tests/api/test_mcp_read.py b/tests/api/test_mcp_read.py new file mode 100644 index 0000000..8f725db --- /dev/null +++ b/tests/api/test_mcp_read.py @@ -0,0 +1,139 @@ +import asyncio +import hashlib +import json +from pathlib import Path + +import httpx2 +import yaml +from mcp import Client +from mcp.client.streamable_http import streamable_http_client +from mcp.types import TextContent + +from gewu.adapters.git_repository import GitCanonicalRepository +from gewu.adapters.sqlite_identity import SQLiteIdentityStore +from gewu.application.reading import CanonicalNodeReader +from gewu.bootstrap.app import create_app +from tests.domain.proposal_fixtures import NODE, SOURCE_REVISION + + +def test_mcp_exposes_only_authenticated_canonical_read(tmp_path: Path) -> None: + async def exercise() -> None: + repository = GitCanonicalRepository(tmp_path / "knowledge.git") + project_key = hashlib.sha256(NODE.project_id.encode()).hexdigest() + node_key = hashlib.sha256(NODE.node_id.encode()).hexdigest() + source_key = hashlib.sha256(SOURCE_REVISION.source_id.encode()).hexdigest() + source_revision_key = hashlib.sha256(SOURCE_REVISION.revision_id.encode()).hexdigest() + repository.publish_files( + expected_head=repository.head(), + files={ + f"projects/{project_key}/nodes/{node_key}.yaml": yaml.safe_dump( + NODE.model_dump(mode="json") + ).encode(), + f"sources/{source_key}/{source_revision_key}.yaml": yaml.safe_dump( + SOURCE_REVISION.model_dump(mode="json") + ).encode(), + }, + message="Publish test node", + index_directory=tmp_path / "publishing", + ) + identities = SQLiteIdentityStore(tmp_path / "identity.sqlite3") + token = identities.provision( + credential_id="reader-key", + principal_id="research-agent", + roles=("agent",), + project_ids=(NODE.project_id,), + ) + app = create_app( + identity_provider=identities, + node_reader=CanonicalNodeReader(repository), + ) + async with app.router.lifespan_context(app): + async with httpx2.AsyncClient( + transport=httpx2.ASGITransport(app=app), + base_url="http://localhost", + headers={"Authorization": f"Bearer {token}"}, + ) as http: + async with Client( + streamable_http_client("http://localhost/mcp", http_client=http) + ) as mcp: + tools = await mcp.list_tools() + assert {tool.name for tool in tools.tools} == { + "diagnostics", + "get_node", + "node_history", + "get_source", + } + diagnostic_result = await mcp.call_tool( + "diagnostics", {"projects": [NODE.project_id]} + ) + assert not diagnostic_result.is_error + diagnostic_text = diagnostic_result.content[0] + assert isinstance(diagnostic_text, TextContent) + diagnostics = json.loads(diagnostic_text.text) + assert diagnostics["transport"] == "mcp" + assert diagnostics["selected_scope"]["projects"] == [NODE.project_id] + assert diagnostics["index_state"] == "not_configured" + assert "get_node" in diagnostics["authorized_operations"] + assert "proposal_submit" not in diagnostics["authorized_operations"] + assert token not in diagnostic_text.text + denied_diagnostics = await mcp.call_tool( + "diagnostics", {"projects": ["interactive-video"]} + ) + assert denied_diagnostics.is_error + result = await mcp.call_tool( + "get_node", + {"project_id": NODE.project_id, "node_id": NODE.node_id}, + ) + assert not result.is_error + content = result.content[0] + assert content.type == "text" + envelope = json.loads(content.text) + assert envelope["canonical_revision"] == repository.head() + assert envelope["data"][0]["node_id"] == NODE.node_id + assert envelope["citations"][0]["quote"] == NODE.claims[0].evidence[0].quote + history = await mcp.call_tool( + "node_history", + {"project_id": NODE.project_id, "node_id": NODE.node_id}, + ) + assert not history.is_error + assert isinstance(history.content[0], TextContent) + assert len(json.loads(history.content[0].text)["data"]) == 1 + source = await mcp.call_tool( + "get_source", + { + "project_id": NODE.project_id, + "revision_id": SOURCE_REVISION.revision_id, + }, + ) + assert not source.is_error + assert isinstance(source.content[0], TextContent) + assert json.loads(source.content[0].text)["data"][0]["revision_id"] == ( + SOURCE_REVISION.revision_id + ) + assert identities.revoke("reader-key") + revoked = await mcp.call_tool( + "get_node", + {"project_id": NODE.project_id, "node_id": NODE.node_id}, + ) + assert revoked.is_error + revoked_message = revoked.content[0] + assert isinstance(revoked_message, TextContent) + assert NODE.node_id not in revoked_message.text + + async with httpx2.AsyncClient( + transport=httpx2.ASGITransport(app=app), + base_url="http://localhost", + ) as http: + async with Client( + streamable_http_client("http://localhost/mcp", http_client=http) + ) as mcp: + unauthenticated = await mcp.call_tool( + "get_node", + {"project_id": NODE.project_id, "node_id": NODE.node_id}, + ) + assert unauthenticated.is_error + error_message = unauthenticated.content[0] + assert isinstance(error_message, TextContent) + assert NODE.node_id not in error_message.text + + asyncio.run(exercise()) diff --git a/tests/api/test_openapi_contract.py b/tests/api/test_openapi_contract.py new file mode 100644 index 0000000..497d571 --- /dev/null +++ b/tests/api/test_openapi_contract.py @@ -0,0 +1,30 @@ +from pathlib import Path +from typing import Any + +from gewu.bootstrap.app import create_local_app +from gewu.domain.operations import OPERATIONS, Transport, operations_for + + +def test_http_openapi_routes_match_the_explicit_operation_allowlist( + tmp_path: Path, monkeypatch: Any +) -> None: + monkeypatch.setenv("GEWU_DATA_ROOT", str(tmp_path / "gewu-data")) + app = create_local_app() + schema = app.openapi() + exposed = { + operation["operationId"]: operation + for methods in schema["paths"].values() + for method, operation in methods.items() + if method in {"get", "post", "put", "delete", "patch"} + } + http_allowlist = {item.name for item in operations_for(Transport.HTTP)} + operation_index = {item.name: item for item in OPERATIONS} + + assert set(exposed) == http_allowlist + assert "owner_decide" not in exposed + assert "proposal_submit" not in exposed + assert all( + not operation_index[name].mutates_knowledge + for name, route in exposed.items() + if "knowledge" in route.get("tags", []) + ) diff --git a/tests/api/test_owner_session.py b/tests/api/test_owner_session.py new file mode 100644 index 0000000..3c060d3 --- /dev/null +++ b/tests/api/test_owner_session.py @@ -0,0 +1,227 @@ +import asyncio +import hashlib +import json +import time + +import httpx2 +import pyotp +from cryptography.fernet import Fernet +from mcp import Client +from mcp.client.streamable_http import streamable_http_client + +from gewu.adapters.git_repository import GitCanonicalRepository +from gewu.adapters.sqlite_identity import SQLiteIdentityStore +from gewu.application.reading import CanonicalNodeReader +from gewu.bootstrap.app import create_app + + +def test_owner_login_returns_a_short_lived_non_agent_token(tmp_path) -> None: + async def exercise() -> None: + repository = GitCanonicalRepository(tmp_path / "knowledge.git") + identities = SQLiteIdentityStore( + tmp_path / "identity.sqlite3", owner_encryption_key=Fernet.generate_key() + ) + secret = pyotp.random_base32() + identities.provision_owner( + principal_id="owner-one", + password="correct horse battery staple", + totp_secret=secret, + project_ids=("dome-performance",), + ) + now = int(time.time()) + identities.issue_owner_challenge( + challenge_id="challenge-one", + owner_principal_id="owner-one", + proposal_id="proposal-one", + proposal_revision=1, + payload_digest="a" * 64, + diff_digest="b" * 64, + domains=("dome-performance",), + expires_at=now + 300, + now=now, + ) + agent_token = identities.provision( + credential_id="agent-key", + principal_id="agent-one", + roles=("agent",), + project_ids=("dome-performance",), + ) + app = create_app( + identity_provider=identities, + owner_identity_provider=identities, + owner_service_audience="gewu-local-test", + node_reader=CanonicalNodeReader(repository), + ) + async with httpx2.AsyncClient( + transport=httpx2.ASGITransport(app=app), base_url="http://127.0.0.1" + ) as client: + response = await client.post( + "/auth/owner-session", + json={ + "principal_id": "owner-one", + "password": "correct horse battery staple", + "totp_code": pyotp.TOTP(secret).now(), + "challenge_id": "challenge-one", + }, + ) + assert response.status_code == 200, response.text + assert response.headers["cache-control"] == "no-store" + token = response.json()["session_token"] + assert token.startswith("gwo_") + assert identities.authenticate(token) is None + assert identities.authenticate(agent_token) is not None + assert ( + identities.authenticate_owner_session( + token, + challenge_id="challenge-one", + audience="gewu-local-test", + ) + is not None + ) + + wrong_origin = await client.post( + "/auth/owner-session", + headers={"Origin": "https://attacker.invalid"}, + json={ + "principal_id": "owner-one", + "password": "correct horse battery staple", + "totp_code": pyotp.TOTP(secret).now(), + "challenge_id": "challenge-two", + }, + ) + assert wrong_origin.status_code == 403 + + oversized = await client.post( + "/auth/owner-session", + content='{"padding":"' + ("x" * 5000) + '"}', + headers={"Content-Type": "application/json"}, + ) + assert oversized.status_code == 413 + + async with httpx2.AsyncClient( + transport=httpx2.ASGITransport(app=app), base_url="http://gewu.example" + ) as remote_http: + insecure = await remote_http.post( + "/auth/owner-session", + json={ + "principal_id": "owner-one", + "password": "correct horse battery staple", + "totp_code": "000000", + "challenge_id": "challenge-three", + }, + ) + assert insecure.status_code == 403 + + asyncio.run(exercise()) + + +def test_owner_login_is_fail_closed_without_encryption_key_or_audience(tmp_path) -> None: + async def exercise() -> None: + repository = GitCanonicalRepository(tmp_path / "knowledge.git") + identities = SQLiteIdentityStore(tmp_path / "identity.sqlite3") + app = create_app( + identity_provider=identities, + owner_identity_provider=identities, + node_reader=CanonicalNodeReader(repository), + ) + async with httpx2.AsyncClient( + transport=httpx2.ASGITransport(app=app), base_url="http://127.0.0.1" + ) as client: + response = await client.post( + "/auth/owner-session", + json={ + "principal_id": "owner-one", + "password": "correct horse battery staple", + "totp_code": "000000", + "challenge_id": "challenge-one", + }, + ) + assert response.status_code == 503 + + asyncio.run(exercise()) + + +def test_dedicated_owner_mcp_reads_diff_and_records_decision(tmp_path) -> None: + async def exercise() -> None: + repository = GitCanonicalRepository(tmp_path / "knowledge.git") + identities = SQLiteIdentityStore( + tmp_path / "identity.sqlite3", owner_encryption_key=Fernet.generate_key() + ) + secret = pyotp.random_base32() + identities.provision_owner( + principal_id="owner-one", + password="correct horse battery staple", + totp_secret=secret, + project_ids=("dome-performance",), + ) + diff_json = '{"files":[{"path":"projects/dome/nodes/a.yaml"}]}' + diff_digest = hashlib.sha256(diff_json.encode()).hexdigest() + identities.issue_owner_challenge( + challenge_id="challenge-review", + owner_principal_id="owner-one", + proposal_id="proposal-one", + proposal_revision=1, + payload_digest="a" * 64, + diff_digest=diff_digest, + domains=("dome-performance",), + expires_at=int(time.time()) + 300, + diff_json=diff_json, + ) + app = create_app( + identity_provider=identities, + owner_identity_provider=identities, + owner_service_audience="gewu-owner-test", + node_reader=CanonicalNodeReader(repository), + ) + async with app.router.lifespan_context(app): + async with httpx2.AsyncClient( + transport=httpx2.ASGITransport(app=app), base_url="http://127.0.0.1" + ) as login_http: + login = await login_http.post( + "/auth/owner-session", + json={ + "principal_id": "owner-one", + "password": "correct horse battery staple", + "totp_code": pyotp.TOTP(secret).now(), + "challenge_id": "challenge-review", + }, + ) + assert login.status_code == 200, login.text + token = login.json()["session_token"] + async with httpx2.AsyncClient( + transport=httpx2.ASGITransport(app=app), + base_url="http://localhost", + headers={"Authorization": f"Bearer {token}"}, + ) as owner_http: + async with Client( + streamable_http_client("http://localhost/owner-mcp", http_client=owner_http) + ) as owner: + names = {tool.name for tool in (await owner.list_tools()).tools} + assert names == { + "owner_challenge_get", + "owner_diff_get", + "owner_decide", + } + challenge = await owner.call_tool( + "owner_challenge_get", {"challenge_id": "challenge-review"} + ) + assert not challenge.is_error + diff = await owner.call_tool( + "owner_diff_get", {"challenge_id": "challenge-review"} + ) + assert not diff.is_error + decision = await owner.call_tool( + "owner_decide", + { + "challenge_id": "challenge-review", + "expected_proposal_revision": 1, + "expected_payload_digest": "a" * 64, + "expected_diff_digest": diff_digest, + "decision": "authorize", + "rationale": "Reviewed this exact change and approve it.", + }, + ) + assert not decision.is_error + assert json.loads(decision.content[0].text)["status"] == "authorized" + + asyncio.run(exercise()) diff --git a/tests/api/test_project_admin.py b/tests/api/test_project_admin.py new file mode 100644 index 0000000..b8911ce --- /dev/null +++ b/tests/api/test_project_admin.py @@ -0,0 +1,153 @@ +import asyncio +from pathlib import Path + +import httpx2 + +from gewu.adapters.git_repository import GitCanonicalRepository +from gewu.adapters.sqlite_identity import SQLiteIdentityStore +from gewu.application.reading import CanonicalNodeReader +from gewu.application.registry import LocalProjectRegistry +from gewu.bootstrap.app import create_app +from gewu.domain.contracts import AutoAcceptPolicy, ProjectRecord, ProjectStatus + + +def test_project_registry_lifecycle_is_admin_only_and_durable(tmp_path: Path) -> None: + async def exercise() -> None: + identities = SQLiteIdentityStore(tmp_path / "identity.sqlite3") + admin_token = identities.provision( + credential_id="project-admin", + principal_id="owner-operator", + roles=("admin",), + ) + viewer_token = identities.provision( + credential_id="project-viewer", + principal_id="research-reader", + roles=("viewer",), + ) + mixed_agent_admin_token = identities.provision( + credential_id="mixed-agent-admin", + principal_id="agent-with-admin-role", + roles=("admin", "agent"), + ) + repository = GitCanonicalRepository(tmp_path / "knowledge.git") + registry = LocalProjectRegistry( + tmp_path / "control" / "registry.json", + repository=repository, + index_directory=tmp_path / "publishing", + authority_lock_path=tmp_path / "workflow" / "locks" / "canonical-writer.lock", + ) + app = create_app( + identity_provider=identities, + node_reader=CanonicalNodeReader(repository), + project_registry=registry, + ) + project = ProjectRecord( + project_id="new-research", + title="New research", + status=ProjectStatus.ACTIVE, + description="An administrator-owned research lens.", + owners=("owner-operator",), + north_star="Investigate a new research area.", + key_questions=("What is known?",), + autonomy_policy_id="new-policy", + ) + policy = AutoAcceptPolicy( + policy_id="new-policy", + policy_version=1, + project_id="new-research", + require_supported_assessment=True, + ) + body = { + "project": project.model_dump(mode="json"), + "policy": policy.model_dump(mode="json"), + } + async with httpx2.AsyncClient( + transport=httpx2.ASGITransport(app=app), base_url="http://test" + ) as http: + assert (await http.get("/v1/admin/projects")).status_code == 401 + denied = await http.post( + "/v1/admin/projects", + json=body, + headers={"Authorization": f"Bearer {viewer_token}"}, + ) + assert denied.status_code == 403 + mixed_agent_denied = await http.post( + "/v1/admin/projects", + json=body, + headers={"Authorization": f"Bearer {mixed_agent_admin_token}"}, + ) + assert mixed_agent_denied.status_code == 403 + oversized = await http.post( + "/v1/admin/projects", + content=b" " * (64 * 1024 + 1), + headers={ + "Authorization": f"Bearer {admin_token}", + "Content-Type": "application/json", + }, + ) + assert oversized.status_code == 413 + + headers = {"Authorization": f"Bearer {admin_token}"} + diagnostics = await http.get("/v1/diagnostics", headers=headers) + assert diagnostics.status_code == 200 + assert { + "admin_list_projects", + "project_create", + "project_archive", + }.issubset(diagnostics.json()["authorized_operations"]) + created = await http.post("/v1/admin/projects", json=body, headers=headers) + assert created.status_code == 201, created.text + assert created.json()["project_id"] == "new-research" + listing = await http.get("/v1/admin/projects", headers=headers) + assert [item["project_id"] for item in listing.json()] == ["new-research"] + duplicate = await http.post("/v1/admin/projects", json=body, headers=headers) + assert duplicate.status_code == 409 + archived = await http.post("/v1/admin/projects/new-research/archive", headers=headers) + assert archived.status_code == 200 + assert archived.json()["status"] == "archived" + assert registry.records()[0].status == ProjectStatus.ARCHIVED + + session = identities.create_browser_session(admin_token) + browser_project = project.model_copy( + update={ + "project_id": "browser-created", + "title": "Browser created", + "autonomy_policy_id": "browser-policy", + } + ) + browser_policy = policy.model_copy( + update={"policy_id": "browser-policy", "project_id": "browser-created"} + ) + browser_body = { + "project": browser_project.model_dump(mode="json"), + "policy": browser_policy.model_dump(mode="json"), + } + cookie = {"Cookie": f"gewu_session={session.session_token}"} + without_csrf = await http.post( + "/v1/admin/projects", + json=browser_body, + headers={**cookie, "Origin": "http://test"}, + ) + assert without_csrf.status_code == 403 + wrong_origin = await http.post( + "/v1/admin/projects", + json=browser_body, + headers={ + **cookie, + "Origin": "https://attacker.example", + "X-GEWU-CSRF": session.csrf_token, + }, + ) + assert wrong_origin.status_code == 403 + browser_created = await http.post( + "/v1/admin/projects", + json=browser_body, + headers={ + **cookie, + "Origin": "http://test", + "X-GEWU-CSRF": session.csrf_token, + }, + ) + assert browser_created.status_code == 201, browser_created.text + + asyncio.run(exercise()) diff --git a/tests/api/test_scope_resolution.py b/tests/api/test_scope_resolution.py new file mode 100644 index 0000000..043e3cc --- /dev/null +++ b/tests/api/test_scope_resolution.py @@ -0,0 +1,439 @@ +import asyncio +import hashlib +import json +from pathlib import Path + +import httpx2 +import yaml +from mcp import Client +from mcp.client.streamable_http import streamable_http_client +from mcp.types import TextContent + +from gewu.adapters.git_repository import GitCanonicalRepository +from gewu.adapters.sqlite_identity import SQLiteIdentityStore +from gewu.adapters.sqlite_search import SQLiteSearchIndex +from gewu.application.reading import CanonicalNodeReader +from gewu.application.search import CanonicalSearchService +from gewu.bootstrap.app import create_app +from gewu.domain.contracts import EdgeEndpoint, Scope, SearchRequest, TypedEdge +from gewu.domain.validation import evidence_digest +from tests.domain.proposal_fixtures import CLAIM, EVIDENCE, NODE, SOURCE_REVISION + + +def _canonical_path(prefix: str, stable_id: str) -> str: + key = hashlib.sha256(stable_id.encode()).hexdigest() + return f"{prefix}/{key}.yaml" + + +def test_project_include_resolution_across_http_mcp_and_direct_reads(tmp_path: Path) -> None: + async def exercise() -> None: + repository = GitCanonicalRepository(tmp_path / "knowledge.git") + dome_node = NODE.model_copy( + update={ + "node_id": "dome:scope-default-node", + "summary": "scopeprobe dome project record", + } + ) + video_source = SOURCE_REVISION.model_copy( + update={ + "source_id": "video:scope-source", + "revision_id": "video:scope-source:r1", + "title": "Video-only scope source", + "ingested_for": ("interactive-video",), + "artifact_sha256": None, + "media_type": "application/octet-stream", + "byte_length": 0, + "original_url": "https://video.example/scope-source", + "archive_status": "reference-only", + } + ) + video_evidence = EVIDENCE.model_copy( + update={"source_revision_id": video_source.revision_id} + ) + video_assessment = CLAIM.assessment.model_copy( + update={"evidence_digests": (evidence_digest(video_evidence),)} + ) + video_claim = CLAIM.model_copy( + update={"evidence": (video_evidence,), "assessment": video_assessment} + ) + video_node = NODE.model_copy( + update={ + "project_id": "interactive-video", + "node_id": "video:scope-default-node", + "title": "Video scope fixture", + "summary": "scopeprobe interactive video project record", + "claims": (video_claim,), + } + ) + edge = TypedEdge( + edge_id="scope:cross-project-edge", + edge_type="compared_with", + source=EdgeEndpoint(kind="node", id=dome_node.node_id), + target=EdgeEndpoint(kind="node", id=video_node.node_id), + ) + files = { + f"projects/{hashlib.sha256(dome_node.project_id.encode()).hexdigest()}/nodes/" + f"{hashlib.sha256(dome_node.node_id.encode()).hexdigest()}.yaml": yaml.safe_dump( + dome_node.model_dump(mode="json") + ).encode(), + f"projects/{hashlib.sha256(video_node.project_id.encode()).hexdigest()}/nodes/" + f"{hashlib.sha256(video_node.node_id.encode()).hexdigest()}.yaml": yaml.safe_dump( + video_node.model_dump(mode="json") + ).encode(), + ( + f"sources/{hashlib.sha256(video_source.source_id.encode()).hexdigest()}/" + f"{hashlib.sha256(video_source.revision_id.encode()).hexdigest()}.yaml" + ): yaml.safe_dump(video_source.model_dump(mode="json")).encode(), + _canonical_path("edges", edge.edge_id): yaml.safe_dump( + edge.model_dump(mode="json") + ).encode(), + } + repository.publish_files( + expected_head=repository.head(), + files=files, + message="Add project-scope fixtures", + index_directory=tmp_path / "publishing", + ) + identities = SQLiteIdentityStore(tmp_path / "identity.sqlite3") + all_scope_token = identities.provision( + credential_id="all-scope-reader", + principal_id="all-scope-reader", + roles=("viewer",), + project_ids=("dome-performance", "interactive-video", "shared"), + ) + dome_only_token = identities.provision( + credential_id="dome-only-reader", + principal_id="dome-only-reader", + roles=("viewer",), + project_ids=("dome-performance", "shared"), + ) + search_index = SQLiteSearchIndex(tmp_path / "search.sqlite3", repository) + indexed_nodes = search_index.canonical_nodes()[1] + assert {node.node_id for node in indexed_nodes} == {dome_node.node_id, video_node.node_id} + lexical_scope_results = search_index.search( + query="scopeprobe", + project_ids=("dome-performance", "interactive-video"), + include_shared=False, + limit=10, + ) + assert len(lexical_scope_results.nodes) == 2, [ + item.node.summary for item in lexical_scope_results.nodes + ] + search = CanonicalSearchService( + index=search_index, + project_ids_provider=lambda: ("dome-performance", "interactive-video"), + canonical_head=repository.head, + default_includes_provider=lambda project_id: ( + ("interactive-video", "shared") if project_id == "dome-performance" else () + ), + ) + app = create_app( + identity_provider=identities, + node_reader=CanonicalNodeReader(repository), + search_service=search, + ) + direct_service_result = search.search( + SearchRequest( + query="scopeprobe", + scope=Scope(projects=("dome-performance",), include_shared=False), + limit=10, + budget_tokens=50000, + ), + principal=identities.authenticate(all_scope_token), + ) + assert len(direct_service_result.data) == 2 + async with app.router.lifespan_context(app): + async with httpx2.AsyncClient( + transport=httpx2.ASGITransport(app=app), base_url="http://localhost" + ) as http: + default = await http.post( + "/v1/search", + json={ + "query": "scopeprobe", + "scope": {"projects": ["dome-performance"], "include_shared": False}, + "limit": 10, + "budget_tokens": 50000, + }, + headers={"Authorization": f"Bearer {all_scope_token}"}, + ) + assert default.status_code == 200, default.text + assert {item["node"]["project_id"] for item in default.json()["data"]} == { + "dome-performance", + "interactive-video", + }, default.json() + assert video_source.revision_id in { + item["source_revision_id"] for item in default.json()["citations"] + } + + explicit_empty = await http.post( + "/v1/search", + json={ + "query": "scopeprobe", + "scope": { + "projects": ["dome-performance"], + "include_projects": [], + "include_shared": False, + }, + "limit": 10, + "budget_tokens": 50000, + }, + headers={"Authorization": f"Bearer {all_scope_token}"}, + ) + assert explicit_empty.status_code == 200, explicit_empty.text + assert {item["node"]["project_id"] for item in explicit_empty.json()["data"]} == { + "dome-performance" + } + assert video_source.revision_id not in { + item["source_revision_id"] for item in explicit_empty.json()["citations"] + } + + explicit_include = await http.post( + "/v1/search", + json={ + "query": "scopeprobe", + "scope": { + "projects": ["dome-performance"], + "include_projects": ["interactive-video"], + "include_shared": False, + }, + "limit": 10, + "budget_tokens": 50000, + }, + headers={"Authorization": f"Bearer {all_scope_token}"}, + ) + assert explicit_include.status_code == 200, explicit_include.text + assert len(explicit_include.json()["data"]) == 2 + + denied_default = await http.post( + "/v1/search", + json={ + "query": "scopeprobe", + "scope": {"projects": ["dome-performance"], "include_shared": False}, + "limit": 10, + "budget_tokens": 50000, + }, + headers={"Authorization": f"Bearer {dome_only_token}"}, + ) + assert denied_default.status_code == 403 + denied_explicit_include = await http.post( + "/v1/search", + json={ + "query": "scopeprobe", + "scope": { + "projects": ["dome-performance"], + "include_projects": ["interactive-video"], + "include_shared": False, + }, + "limit": 10, + "budget_tokens": 50000, + }, + headers={"Authorization": f"Bearer {dome_only_token}"}, + ) + assert denied_explicit_include.status_code == 403 + allowed_empty = await http.post( + "/v1/search", + json={ + "query": "scopeprobe", + "scope": { + "projects": ["dome-performance"], + "include_projects": [], + "include_shared": False, + }, + "limit": 10, + "budget_tokens": 50000, + }, + headers={"Authorization": f"Bearer {dome_only_token}"}, + ) + assert allowed_empty.status_code == 200 + + default_neighbors = await http.post( + "/v1/neighbors", + json={ + "node_id": dome_node.node_id, + "scope": {"projects": ["dome-performance"], "include_shared": False}, + "limit": 10, + "budget_tokens": 50000, + }, + headers={"Authorization": f"Bearer {all_scope_token}"}, + ) + assert default_neighbors.status_code == 200, default_neighbors.text + assert default_neighbors.json()["data"][0]["neighbor"]["id"] == video_node.node_id + restricted_neighbors = await http.post( + "/v1/neighbors", + json={ + "node_id": dome_node.node_id, + "scope": { + "projects": ["dome-performance"], + "include_projects": [], + "include_shared": False, + }, + "limit": 10, + "budget_tokens": 50000, + }, + headers={"Authorization": f"Bearer {all_scope_token}"}, + ) + assert restricted_neighbors.status_code == 200 + assert restricted_neighbors.json()["data"] == [] + + default_sources = await http.post( + "/v1/sources", + json={ + "scope": {"projects": ["dome-performance"], "include_shared": False}, + "limit": 10, + "budget_tokens": 50000, + }, + headers={"Authorization": f"Bearer {all_scope_token}"}, + ) + assert default_sources.status_code == 200, default_sources.text + assert video_source.revision_id in { + item["source"]["revision_id"] for item in default_sources.json()["data"] + } + explicit_empty_sources = await http.post( + "/v1/sources", + json={ + "scope": { + "projects": ["dome-performance"], + "include_projects": [], + "include_shared": False, + }, + "limit": 10, + "budget_tokens": 50000, + }, + headers={"Authorization": f"Bearer {all_scope_token}"}, + ) + assert explicit_empty_sources.status_code == 200 + assert video_source.revision_id not in { + item["source"]["revision_id"] for item in explicit_empty_sources.json()["data"] + } + + direct_node_denied = await http.get( + f"/v1/projects/interactive-video/nodes/{video_node.node_id}", + headers={"Authorization": f"Bearer {dome_only_token}"}, + ) + assert direct_node_denied.status_code == 403 + direct_source_denied = await http.get( + f"/v1/projects/interactive-video/sources/{video_source.revision_id}", + headers={"Authorization": f"Bearer {dome_only_token}"}, + ) + assert direct_source_denied.status_code == 403 + direct_node_allowed = await http.get( + f"/v1/projects/interactive-video/nodes/{video_node.node_id}", + headers={"Authorization": f"Bearer {all_scope_token}"}, + ) + assert direct_node_allowed.status_code == 200 + direct_source_allowed = await http.get( + f"/v1/projects/interactive-video/sources/{video_source.revision_id}", + headers={"Authorization": f"Bearer {all_scope_token}"}, + ) + assert direct_source_allowed.status_code == 200 + + http.headers["Authorization"] = f"Bearer {all_scope_token}" + async with Client( + streamable_http_client("http://localhost/mcp", http_client=http) + ) as mcp: + mcp_default = await mcp.call_tool( + "search", + { + "query": "scopeprobe", + "projects": ["dome-performance"], + "include_shared": False, + "all_projects": False, + "limit": 10, + "budget_tokens": 50000, + }, + ) + assert not mcp_default.is_error + assert isinstance(mcp_default.content[0], TextContent) + default_payload = json.loads(mcp_default.content[0].text) + assert len(default_payload["data"]) == 2 + + mcp_default_sources = await mcp.call_tool( + "list_sources", + { + "projects": ["dome-performance"], + "include_shared": False, + "all_projects": False, + "limit": 10, + "budget_tokens": 50000, + }, + ) + assert not mcp_default_sources.is_error + assert isinstance(mcp_default_sources.content[0], TextContent) + assert video_source.revision_id in { + row["source"]["revision_id"] + for row in json.loads(mcp_default_sources.content[0].text)["data"] + } + + mcp_default_neighbors = await mcp.call_tool( + "neighbors", + { + "node_id": dome_node.node_id, + "projects": ["dome-performance"], + "include_shared": False, + "all_projects": False, + "edge_types": [], + "limit": 10, + "budget_tokens": 50000, + }, + ) + assert not mcp_default_neighbors.is_error + assert isinstance(mcp_default_neighbors.content[0], TextContent) + assert ( + json.loads(mcp_default_neighbors.content[0].text)["data"][0]["neighbor"][ + "id" + ] + == video_node.node_id + ) + + mcp_explicit_empty = await mcp.call_tool( + "search", + { + "query": "scopeprobe", + "projects": ["dome-performance"], + "include_projects": [], + "include_shared": False, + "all_projects": False, + "limit": 10, + "budget_tokens": 50000, + }, + ) + assert not mcp_explicit_empty.is_error + assert isinstance(mcp_explicit_empty.content[0], TextContent) + assert len(json.loads(mcp_explicit_empty.content[0].text)["data"]) == 1 + + mcp_empty_sources = await mcp.call_tool( + "list_sources", + { + "projects": ["dome-performance"], + "include_projects": [], + "include_shared": False, + "all_projects": False, + "limit": 10, + "budget_tokens": 50000, + }, + ) + assert not mcp_empty_sources.is_error + assert isinstance(mcp_empty_sources.content[0], TextContent) + assert video_source.revision_id not in { + row["source"]["revision_id"] + for row in json.loads(mcp_empty_sources.content[0].text)["data"] + } + + mcp_empty_neighbors = await mcp.call_tool( + "neighbors", + { + "node_id": dome_node.node_id, + "projects": ["dome-performance"], + "include_projects": [], + "include_shared": False, + "all_projects": False, + "edge_types": [], + "limit": 10, + "budget_tokens": 50000, + }, + ) + assert not mcp_empty_neighbors.is_error + assert isinstance(mcp_empty_neighbors.content[0], TextContent) + assert json.loads(mcp_empty_neighbors.content[0].text)["data"] == [] + + asyncio.run(exercise()) diff --git a/tests/api/test_search_api.py b/tests/api/test_search_api.py new file mode 100644 index 0000000..5e33030 --- /dev/null +++ b/tests/api/test_search_api.py @@ -0,0 +1,616 @@ +import asyncio +import hashlib +import json +from datetime import UTC, datetime +from pathlib import Path + +import httpx2 +import yaml +from mcp import Client +from mcp.client.streamable_http import streamable_http_client +from mcp.types import TextContent + +from gewu.adapters.git_repository import GitCanonicalRepository +from gewu.adapters.sqlite_identity import SQLiteIdentityStore +from gewu.adapters.sqlite_search import SQLiteSearchIndex +from gewu.application.reading import CanonicalNodeReader +from gewu.application.search import CanonicalSearchService +from gewu.bootstrap.app import create_app +from gewu.domain.contracts import EdgeEndpoint, NodeType, TypedEdge +from tests.domain.proposal_fixtures import NODE, PROJECTS, SOURCE_REVISION + + +def test_authenticated_search_scope_through_http_and_mcp(tmp_path: Path) -> None: + async def exercise() -> None: + repository = GitCanonicalRepository(tmp_path / "knowledge.git") + project_key = hashlib.sha256(NODE.project_id.encode()).hexdigest() + node_key = hashlib.sha256(NODE.node_id.encode()).hexdigest() + source_key = hashlib.sha256(SOURCE_REVISION.revision_id.encode()).hexdigest() + stale_node = NODE.model_copy( + update={ + "as_of": datetime(2024, 1, 1, tzinfo=UTC), + "stale_after": datetime(2025, 1, 1, tzinfo=UTC), + } + ) + problem_node = NODE.model_copy( + update={ + "node_id": "dome:problem-browse-fixture", + "node_type": NodeType.PROBLEM, + "title": "Problem browse fixture", + "value": None, + "unit": None, + } + ) + problem_key = hashlib.sha256(problem_node.node_id.encode()).hexdigest() + older_source = SOURCE_REVISION.model_copy( + update={ + "source_id": "dome:older-pilot-report", + "revision_id": "dome:older-pilot-report:r1", + "title": "Older pilot installation report", + "captured_at": datetime(2025, 1, 1, tzinfo=UTC), + } + ) + related = NODE.model_copy(update={"node_id": "z:dome-related"}) + related_key = hashlib.sha256(related.node_id.encode()).hexdigest() + related_two = NODE.model_copy(update={"node_id": "zz:dome-related-two"}) + related_two_key = hashlib.sha256(related_two.node_id.encode()).hexdigest() + edge = TypedEdge( + edge_id="dome:related-edge", + edge_type="compared_with", + source=EdgeEndpoint(kind="node", id=NODE.node_id), + target=EdgeEndpoint(kind="node", id=related.node_id), + ) + edge_two = TypedEdge( + edge_id="dome:related-edge-two", + edge_type="compared_with", + source=EdgeEndpoint(kind="node", id=NODE.node_id), + target=EdgeEndpoint(kind="node", id=related_two.node_id), + ) + repository.publish_files( + expected_head=repository.head(), + files={ + f"projects/{project_key}/nodes/{node_key}.yaml": yaml.safe_dump( + stale_node.model_dump(mode="json") + ).encode(), + f"projects/{project_key}/nodes/{problem_key}.yaml": yaml.safe_dump( + problem_node.model_dump(mode="json") + ).encode(), + f"projects/{project_key}/nodes/{related_key}.yaml": yaml.safe_dump( + related.model_dump(mode="json") + ).encode(), + f"projects/{project_key}/nodes/{related_two_key}.yaml": yaml.safe_dump( + related_two.model_dump(mode="json") + ).encode(), + ( + f"sources/{hashlib.sha256(SOURCE_REVISION.source_id.encode()).hexdigest()}/" + f"{source_key}.yaml" + ): yaml.safe_dump(SOURCE_REVISION.model_dump(mode="json")).encode(), + ( + f"sources/{hashlib.sha256(older_source.source_id.encode()).hexdigest()}/" + f"{hashlib.sha256(older_source.revision_id.encode()).hexdigest()}.yaml" + ): yaml.safe_dump(older_source.model_dump(mode="json")).encode(), + f"edges/{hashlib.sha256(edge.edge_id.encode()).hexdigest()}.yaml": yaml.safe_dump( + edge.model_dump(mode="json") + ).encode(), + ( + f"edges/{hashlib.sha256(edge_two.edge_id.encode()).hexdigest()}.yaml" + ): yaml.safe_dump(edge_two.model_dump(mode="json")).encode(), + }, + message="Add searchable node", + index_directory=tmp_path / "publishing", + ) + identities = SQLiteIdentityStore(tmp_path / "identity.sqlite3") + token = identities.provision( + credential_id="search-key", + principal_id="search-reader", + roles=("viewer",), + project_ids=("dome-performance",), + ) + search = CanonicalSearchService( + index=SQLiteSearchIndex(tmp_path / "search.sqlite3", repository), + project_ids_provider=lambda: PROJECTS.keys(), + canonical_head=repository.head, + ) + app = create_app( + identity_provider=identities, + node_reader=CanonicalNodeReader(repository), + search_service=search, + ) + async with app.router.lifespan_context(app): + async with httpx2.AsyncClient( + transport=httpx2.ASGITransport(app=app), + base_url="http://localhost", + headers={"Authorization": f"Bearer {token}"}, + ) as http: + diagnostic_unbuilt = await http.get("/v1/diagnostics?projects=dome-performance") + assert diagnostic_unbuilt.status_code == 200 + unbuilt_state = diagnostic_unbuilt.json() + assert unbuilt_state["index_state"] == "not_built" + assert unbuilt_state["index_revision"] is None + assert unbuilt_state["index_schema_version"] == 4 + result = await http.post( + "/v1/search", + json={ + "query": "400 visitors", + "scope": { + "projects": ["dome-performance"], + "include_shared": False, + }, + "limit": 10, + "budget_tokens": 5000, + }, + ) + assert result.status_code == 200, result.text + envelope = result.json() + assert envelope["retrieval_mode"] == "keyword" + assert envelope["index_revision"] == repository.head() + assert envelope["data"][0]["node"]["node_id"] == NODE.node_id + assert envelope["citations"][0]["quote"] == NODE.claims[0].evidence[0].quote + listed_sources = await http.post( + "/v1/sources", + json={ + "scope": {"projects": ["dome-performance"], "include_shared": False}, + "limit": 10, + "budget_tokens": 5000, + }, + ) + assert listed_sources.status_code == 200, listed_sources.text + assert listed_sources.json()["data"][0]["source"]["revision_id"] == ( + SOURCE_REVISION.revision_id + ) + source_page_one = await http.post( + "/v1/sources", + json={ + "scope": {"projects": ["dome-performance"], "include_shared": False}, + "limit": 1, + "budget_tokens": 5000, + }, + ) + source_cursor = source_page_one.json()["next_cursor"] + assert source_cursor + source_page_two = await http.post( + "/v1/sources", + json={ + "scope": {"projects": ["dome-performance"], "include_shared": False}, + "limit": 1, + "cursor": source_cursor, + "budget_tokens": 5000, + }, + ) + assert source_page_two.status_code == 200 + assert source_page_two.json()["data"][0]["source"]["revision_id"] == ( + older_source.revision_id + ) + denied_sources = await http.post( + "/v1/sources", + json={ + "scope": {"projects": ["interactive-video"], "include_shared": False}, + "limit": 10, + "budget_tokens": 5000, + }, + ) + assert denied_sources.status_code == 403 + problem_filter = await http.post( + "/v1/search", + json={ + "query": "unmatched phrase", + "scope": {"projects": ["dome-performance"], "include_shared": False}, + "node_types": ["problem"], + "budget_tokens": 5000, + }, + ) + assert problem_filter.status_code == 200, problem_filter.text + assert problem_filter.json()["data"] == [] + problem_browse = await http.post( + "/v1/search", + json={ + "query": "", + "scope": {"projects": ["dome-performance"], "include_shared": False}, + "node_types": ["problem"], + "budget_tokens": 5000, + }, + ) + assert problem_browse.status_code == 200, problem_browse.text + assert [row["node"]["node_id"] for row in problem_browse.json()["data"]] == [ + problem_node.node_id + ] + unbounded_browse = await http.post( + "/v1/search", + json={ + "query": "", + "scope": {"projects": ["dome-performance"], "include_shared": False}, + "budget_tokens": 5000, + }, + ) + assert unbounded_browse.status_code == 422 + stale_filter = await http.post( + "/v1/search", + json={ + "query": "400 visitors", + "scope": {"projects": ["dome-performance"], "include_shared": False}, + "stale_only": True, + "budget_tokens": 5000, + }, + ) + assert stale_filter.status_code == 200, stale_filter.text + assert stale_filter.json()["data"][0]["node"]["node_id"] == NODE.node_id + facet_filter = await http.post( + "/v1/search", + json={ + "query": "400 visitors", + "scope": {"projects": ["dome-performance"], "include_shared": False}, + "tags": ["attendance"], + "audience": ["product"], + "use_for": ["comparison"], + "domain": ["production"], + "budget_tokens": 5000, + }, + ) + assert facet_filter.status_code == 200, facet_filter.text + assert facet_filter.json()["data"][0]["node"]["node_id"] == NODE.node_id + unmatched_facet = await http.post( + "/v1/search", + json={ + "query": "400 visitors", + "scope": {"projects": ["dome-performance"], "include_shared": False}, + "domain": ["unrelated-domain"], + "budget_tokens": 5000, + }, + ) + assert unmatched_facet.status_code == 200, unmatched_facet.text + assert unmatched_facet.json()["data"] == [] + diagnostic_current = await http.get("/v1/diagnostics?projects=dome-performance") + assert diagnostic_current.status_code == 200 + current_state = diagnostic_current.json() + assert current_state["index_state"] == "current" + assert current_state["index_revision"] == repository.head() + assert "proposal_submit" not in current_state["authorized_operations"] + projects = await http.get("/v1/projects") + assert projects.status_code == 200 + assert projects.json() == ["dome-performance"] + page_one = await http.post( + "/v1/search", + json={ + "query": "visitors", + "scope": {"projects": ["dome-performance"], "include_shared": False}, + "limit": 1, + "budget_tokens": 5000, + }, + ) + assert page_one.status_code == 200 + cursor = page_one.json()["next_cursor"] + assert cursor + page_two = await http.post( + "/v1/search", + json={ + "query": "visitors", + "scope": {"projects": ["dome-performance"], "include_shared": False}, + "limit": 1, + "cursor": cursor, + "budget_tokens": 5000, + }, + ) + assert page_two.status_code == 200 + assert ( + page_two.json()["data"][0]["node"]["node_id"] + != page_one.json()["data"][0]["node"]["node_id"] + ) + reused_cursor = await http.post( + "/v1/search", + json={ + "query": "400 visitors", + "scope": {"projects": ["dome-performance"], "include_shared": False}, + "limit": 1, + "cursor": cursor, + "budget_tokens": 5000, + }, + ) + assert reused_cursor.status_code == 422 + changed_facet_cursor = await http.post( + "/v1/search", + json={ + "query": "visitors", + "scope": {"projects": ["dome-performance"], "include_shared": False}, + "node_types": ["problem"], + "limit": 1, + "cursor": cursor, + "budget_tokens": 5000, + }, + ) + assert changed_facet_cursor.status_code == 422 + changed_tag_cursor = await http.post( + "/v1/search", + json={ + "query": "visitors", + "scope": {"projects": ["dome-performance"], "include_shared": False}, + "tags": ["attendance"], + "limit": 1, + "cursor": cursor, + "budget_tokens": 5000, + }, + ) + assert changed_tag_cursor.status_code == 422 + adjacent = await http.post( + "/v1/neighbors", + json={ + "node_id": NODE.node_id, + "scope": {"projects": ["dome-performance"], "include_shared": False}, + "limit": 1, + "budget_tokens": 5000, + }, + ) + assert adjacent.status_code == 200, adjacent.text + assert adjacent.json()["data"][0]["neighbor"]["id"] == related.node_id + neighbor_cursor = adjacent.json()["next_cursor"] + assert neighbor_cursor + adjacent_two = await http.post( + "/v1/neighbors", + json={ + "node_id": NODE.node_id, + "scope": {"projects": ["dome-performance"], "include_shared": False}, + "limit": 1, + "cursor": neighbor_cursor, + "budget_tokens": 5000, + }, + ) + assert adjacent_two.status_code == 200 + assert adjacent_two.json()["data"][0]["neighbor"]["id"] == related_two.node_id + assert adjacent_two.json()["next_cursor"] is None + assert {citation["quote"] for citation in adjacent.json()["citations"]} == { + NODE.claims[0].evidence[0].quote + } + newer = NODE.model_copy(update={"node_id": "new:unrelated-revision-node"}) + newer_key = hashlib.sha256(newer.node_id.encode()).hexdigest() + repository.publish_files( + expected_head=repository.head(), + files={ + f"projects/{project_key}/nodes/{newer_key}.yaml": yaml.safe_dump( + newer.model_dump(mode="json") + ).encode() + }, + message="Advance canonical index for cursor-expiry check", + index_directory=tmp_path / "publishing", + ) + diagnostic_lag = await http.get("/v1/diagnostics?projects=dome-performance") + assert diagnostic_lag.status_code == 200 + lag_state = diagnostic_lag.json() + assert lag_state["index_state"] == "lagging" + assert lag_state["index_revision"] != repository.head() + assert lag_state["canonical_revision"] == repository.head() + expired_search = await http.post( + "/v1/search", + json={ + "query": "visitors", + "scope": {"projects": ["dome-performance"], "include_shared": False}, + "limit": 1, + "cursor": cursor, + "budget_tokens": 5000, + }, + ) + assert expired_search.status_code == 409 + expired_neighbors = await http.post( + "/v1/neighbors", + json={ + "node_id": NODE.node_id, + "scope": {"projects": ["dome-performance"], "include_shared": False}, + "limit": 1, + "cursor": neighbor_cursor, + "budget_tokens": 5000, + }, + ) + assert expired_neighbors.status_code == 409 + too_small = await http.post( + "/v1/search", + json={ + "query": "visitors", + "scope": { + "projects": ["dome-performance"], + "include_shared": False, + }, + "budget_tokens": 256, + }, + ) + assert too_small.status_code == 413 + dated_confidence_search = await http.post( + "/v1/search", + json={ + "query": "visitors", + "scope": { + "projects": ["dome-performance"], + "include_shared": False, + }, + "confidences": ["medium"], + "as_of_from": "2024-01-01", + "as_of_to": "2024-12-31", + "budget_tokens": 5000, + }, + ) + assert dated_confidence_search.status_code == 200, dated_confidence_search.text + assert [ + row["node"]["node_id"] for row in dated_confidence_search.json()["data"] + ] == [NODE.node_id] + filtered_page = await http.post( + "/v1/search", + json={ + "query": "visitors", + "scope": { + "projects": ["dome-performance"], + "include_shared": False, + }, + "confidences": ["medium"], + "limit": 1, + "budget_tokens": 5000, + }, + ) + assert filtered_page.status_code == 200 + filter_cursor = filtered_page.json()["next_cursor"] + assert filter_cursor + changed_filter_page = await http.post( + "/v1/search", + json={ + "query": "visitors", + "scope": { + "projects": ["dome-performance"], + "include_shared": False, + }, + "confidences": ["low"], + "limit": 1, + "cursor": filter_cursor, + "budget_tokens": 5000, + }, + ) + assert changed_filter_page.status_code == 422 + forbidden = await http.post( + "/v1/search", + json={ + "query": "visitors", + "scope": { + "projects": ["interactive-video"], + "include_shared": False, + }, + "budget_tokens": 5000, + }, + ) + assert forbidden.status_code == 403 + async with Client( + streamable_http_client("http://localhost/mcp", http_client=http) + ) as mcp: + tools = await mcp.list_tools() + assert {tool.name for tool in tools.tools} == { + "diagnostics", + "get_node", + "node_history", + "get_source", + "list_projects", + "search", + "neighbors", + "list_sources", + } + listed = await mcp.call_tool("list_projects", {}) + assert not listed.is_error + assert isinstance(listed.content[0], TextContent) + assert json.loads(listed.content[0].text) == ["dome-performance"] + searched = await mcp.call_tool( + "search", + { + "query": "400 visitors", + "projects": ["dome-performance"], + "include_shared": False, + "all_projects": False, + "limit": 10, + "budget_tokens": 5000, + }, + ) + assert not searched.is_error + assert isinstance(searched.content[0], TextContent) + assert ( + json.loads(searched.content[0].text)["data"][0]["node"]["node_id"] + == NODE.node_id + ) + dated_confidence_tool = await mcp.call_tool( + "search", + { + "query": "visitors", + "projects": ["dome-performance"], + "include_shared": False, + "all_projects": False, + "limit": 10, + "budget_tokens": 5000, + "confidences": ["medium"], + "as_of_from": "2024-01-01", + "as_of_to": "2024-12-31", + }, + ) + assert not dated_confidence_tool.is_error + assert [ + row["node"]["node_id"] + for row in json.loads(dated_confidence_tool.content[0].text)["data"] + ] == [NODE.node_id] + filtered_search = await mcp.call_tool( + "search", + { + "query": "unmatched phrase", + "projects": ["dome-performance"], + "include_shared": False, + "all_projects": False, + "limit": 10, + "budget_tokens": 5000, + "node_types": ["problem"], + }, + ) + assert not filtered_search.is_error + assert json.loads(filtered_search.content[0].text)["data"] == [] + browsed_problems = await mcp.call_tool( + "search", + { + "query": "", + "projects": ["dome-performance"], + "include_shared": False, + "all_projects": False, + "limit": 10, + "budget_tokens": 5000, + "node_types": ["problem"], + }, + ) + assert not browsed_problems.is_error + assert [ + row["node"]["node_id"] + for row in json.loads(browsed_problems.content[0].text)["data"] + ] == [problem_node.node_id] + facet_search = await mcp.call_tool( + "search", + { + "query": "400 visitors", + "projects": ["dome-performance"], + "include_shared": False, + "all_projects": False, + "limit": 10, + "budget_tokens": 5000, + "tags": ["attendance"], + "audience": ["product"], + "use_for": ["comparison"], + "domain": ["production"], + }, + ) + assert not facet_search.is_error + assert ( + json.loads(facet_search.content[0].text)["data"][0]["node"]["node_id"] + == NODE.node_id + ) + listed_sources_tool = await mcp.call_tool( + "list_sources", + { + "projects": ["dome-performance"], + "include_shared": False, + "all_projects": False, + "limit": 10, + "budget_tokens": 5000, + }, + ) + assert not listed_sources_tool.is_error + assert ( + json.loads(listed_sources_tool.content[0].text)["data"][0]["source"][ + "revision_id" + ] + == SOURCE_REVISION.revision_id + ) + adjacent_tool = await mcp.call_tool( + "neighbors", + { + "node_id": NODE.node_id, + "projects": ["dome-performance"], + "include_shared": False, + "all_projects": False, + "edge_types": [], + "limit": 10, + "budget_tokens": 5000, + }, + ) + assert not adjacent_tool.is_error + assert isinstance(adjacent_tool.content[0], TextContent) + assert ( + json.loads(adjacent_tool.content[0].text)["data"][0]["neighbor"]["id"] + == related.node_id + ) + + asyncio.run(exercise()) diff --git a/tests/api/test_stdio_connector.py b/tests/api/test_stdio_connector.py new file mode 100644 index 0000000..4d4b508 --- /dev/null +++ b/tests/api/test_stdio_connector.py @@ -0,0 +1,374 @@ +import asyncio +import base64 +import hashlib +import json +from collections.abc import AsyncIterator +from contextlib import asynccontextmanager +from pathlib import Path + +import pytest +from mcp import Client +from mcp.server import MCPServer +from mcp.types import CallToolResult, TextContent + +from gewu.domain.contracts import SourceRevisionRecord +from gewu.transports.stdio_connector import ( + SelectedFile, + UploadStateStore, + _local_source_id, + _local_source_revision_id, + _read_selected_file, + _remote_factory, + _selected_media_type, + create_connector_server, + upload_selected_file, +) + + +def test_connector_uploads_allowlisted_bytes_without_exposing_them_to_local_tools( + tmp_path: Path, +) -> None: + async def exercise() -> None: + source = tmp_path / "selected.md" + payload = b"# Private source\n" + b"Exact source bytes stay in the connector.\n" * 20_000 + source.write_bytes(payload) + backend = MCPServer("test GEWU backend") + chunks: list[bytes] = [] + interrupted = False + extraction_interrupted = False + expected_digest = hashlib.sha256(payload).hexdigest() + + @backend.tool(name="source_upload_begin", structured_output=False) + def begin( + project_id: str, + filename: str, + media_type: str, + byte_length: int, + expected_sha256: str, + ) -> str: + assert (project_id, filename, media_type, byte_length, expected_sha256) == ( + "dome-performance", + "selected.md", + "text/markdown", + len(payload), + expected_digest, + ) + return json.dumps({"upload_id": "opaque-upload-id"}) + + @backend.tool(name="source_upload_chunk", structured_output=False) + def chunk(upload_id: str, offset: int, content_base64: str, chunk_sha256: str) -> str: + nonlocal interrupted + decoded = base64.b64decode(content_base64, validate=True) + assert upload_id == "opaque-upload-id" + assert hashlib.sha256(decoded).hexdigest() == chunk_sha256 + assert offset == sum(map(len, chunks)) + if offset > 0 and not interrupted: + interrupted = True + raise ValueError("simulated interrupted transfer") + chunks.append(decoded) + return json.dumps({"upload_id": upload_id}) + + @backend.tool(name="source_upload_status", structured_output=False) + def status(upload_id: str) -> str: + assert upload_id == "opaque-upload-id" + return json.dumps({"verified_offsets": [0] if chunks else []}) + + @backend.tool(name="source_upload_finalize", structured_output=False) + def finalize(upload_id: str) -> str: + assert upload_id == "opaque-upload-id" + return json.dumps( + {"sha256": hashlib.sha256(b"".join(chunks)).hexdigest(), "status": "staged"} + ) + + @backend.tool(name="source_extract", structured_output=False) + def extract(project_id: str, artifact_sha256: str, source_revision_id: str) -> str: + nonlocal extraction_interrupted + assert project_id == "dome-performance" + assert artifact_sha256 == expected_digest + assert source_revision_id == "source-r1" + if not extraction_interrupted: + extraction_interrupted = True + raise ValueError("simulated extraction interruption") + return json.dumps({"extraction_id": "e" * 64, "blocks": []}) + + @asynccontextmanager + async def remote_factory() -> AsyncIterator[Client]: + async with Client(backend) as remote: + yield remote + + state_file = tmp_path / "connector-state.json" + selected_files = (SelectedFile("research-note", source),) + + async def connector_call() -> CallToolResult: + connector_after_restart = create_connector_server( + selected_files, remote_factory, UploadStateStore(state_file) + ) + async with Client(connector_after_restart) as local_after_restart: + return await local_after_restart.call_tool( + "upload_selected_file", + { + "file_id": "research-note", + "project_id": "dome-performance", + "source_revision_id": "source-r1", + }, + ) + + connector = create_connector_server( + selected_files, remote_factory, UploadStateStore(state_file) + ) + async with Client(connector) as local: + tools = await local.list_tools() + assert {tool.name for tool in tools.tools} == { + "list_selected_files", + "upload_selected_file", + } + listed = await local.call_tool("list_selected_files", {}) + assert not listed.is_error + assert isinstance(listed.content[0], TextContent) + assert "research-note" in listed.content[0].text + assert str(source) not in listed.content[0].text + denied = await local.call_tool( + "upload_selected_file", + { + "file_id": "arbitrary-path", + "project_id": "dome-performance", + "source_revision_id": "source-r1", + }, + ) + assert denied.is_error + interrupted_result = await connector_call() + assert interrupted_result.is_error + assert state_file.exists() + finalize_interrupted_result = await connector_call() + assert finalize_interrupted_result.is_error + assert json.loads(state_file.read_text()) + restarted_result = await connector_call() + assert not restarted_result.is_error + assert isinstance(restarted_result.content[0], TextContent) + receipt = json.loads(restarted_result.content[0].text) + assert receipt["sha256"] == expected_digest + assert receipt["byte_length"] == len(payload) + assert payload[:80].decode() not in restarted_result.content[0].text + source_candidate = SourceRevisionRecord.model_validate_json( + json.dumps(receipt["source_candidate"]), strict=True + ) + assert source_candidate.archive_status == "archived" + assert source_candidate.capture_method == "local-file" + assert source_candidate.source_id.startswith("local:") + assert source_candidate.revision_id == "source-r1" + assert source_candidate.artifact_sha256 == expected_digest + assert str(source) not in restarted_result.content[0].text + assert b"".join(chunks) == payload + state = json.loads(state_file.read_text()) + assert len(state) == 1 + assert "upload_id" not in next(iter(state.values())) + duplicate_result = await connector_call() + assert not duplicate_result.is_error + assert json.loads(duplicate_result.content[0].text) == receipt + + asyncio.run(exercise()) + + +def test_connector_rejects_symlinked_selected_paths(tmp_path: Path) -> None: + outside = tmp_path / "outside.md" + outside.write_text("private") + link = tmp_path / "selected.md" + link.symlink_to(outside) + with pytest.raises(ValueError, match="symbolic links"): + _read_selected_file(SelectedFile("selected", link)) + + +def test_local_file_identity_is_path_free_and_revisions_follow_content() -> None: + from datetime import UTC, datetime + + captured_at = datetime(2026, 10, 13, tzinfo=UTC) + source_id = _local_source_id("research-note") + first = _local_source_revision_id(source_id, hashlib.sha256(b"first").hexdigest(), captured_at) + second = _local_source_revision_id( + source_id, hashlib.sha256(b"second").hexdigest(), captured_at + ) + assert source_id.startswith("local:") + assert "/" not in source_id + assert first != second + assert first.startswith(f"{source_id}:r:") + assert len(first) <= 128 + + +def test_selected_file_media_types_are_explicit() -> None: + assert _selected_media_type(Path("source.pdf")) == "application/pdf" + assert _selected_media_type(Path("source.md")) == "text/markdown" + assert _selected_media_type(Path("source.html")) == "text/html" + assert _selected_media_type(Path("source.txt")) == "text/plain" + assert _selected_media_type(Path("source.vtt")) == "text/vtt" + assert _selected_media_type(Path("source.srt")) == "application/x-subrip" + with pytest.raises(ValueError, match="not supported"): + _selected_media_type(Path("source.exe")) + + +def test_vtt_connector_candidate_is_marked_as_transcript(tmp_path: Path) -> None: + async def exercise() -> None: + source = tmp_path / "interview.vtt" + content = b"WEBVTT\n\n00:00:01.000 --> 00:00:02.000\nExact words.\n" + source.write_bytes(content) + digest = hashlib.sha256(content).hexdigest() + + class Remote: + async def call_tool(self, name: str, arguments: dict[str, object]) -> object: + response: dict[str, object] + if name == "source_upload_begin": + response = {"upload_id": "transcript-upload"} + elif name == "source_upload_chunk": + response = {"upload_id": "transcript-upload"} + elif name == "source_upload_finalize": + response = {"sha256": digest} + elif name == "source_extract": + response = {"status": "complete", "extraction_id": "e" * 64, "blocks": []} + else: + raise AssertionError(f"Unexpected remote tool: {name}") + return type( + "ToolResult", + (), + { + "is_error": False, + "content": [TextContent(type="text", text=json.dumps(response))], + }, + )() + + receipt = await upload_selected_file( + Remote(), SelectedFile("interview", source), project_id="dome-performance" + ) + candidate = SourceRevisionRecord.model_validate_json( + json.dumps(receipt["source_candidate"]), strict=True + ) + assert candidate.capture_method == "transcript" + + asyncio.run(exercise()) + + +def test_local_connector_returns_archived_candidate_when_pdf_extraction_is_unavailable( + tmp_path: Path, +) -> None: + async def exercise() -> None: + source = tmp_path / "scanned.pdf" + content = b"%PDF-1.4 fixture" + source.write_bytes(content) + digest = hashlib.sha256(content).hexdigest() + calls: list[str] = [] + + class Remote: + async def call_tool(self, name: str, arguments: dict[str, object]) -> object: + calls.append(name) + response: dict[str, object] + if name == "source_upload_begin": + response = {"upload_id": "active-upload"} + elif name == "source_upload_chunk": + assert arguments["chunk_sha256"] == digest + response = {"upload_id": "active-upload"} + elif name == "source_upload_finalize": + response = {"sha256": digest} + elif name == "source_extract": + response = { + "status": "unavailable", + "error_code": "PDF_NO_TEXT", + "blocks": [], + } + else: + raise AssertionError(f"Unexpected remote tool: {name}") + return type( + "ToolResult", + (), + { + "is_error": False, + "content": [TextContent(type="text", text=json.dumps(response))], + }, + )() + + state = UploadStateStore(tmp_path / "state.json") + first = await upload_selected_file( + Remote(), + SelectedFile("scanned-paper", source), + project_id="dome-performance", + state_store=state, + ) + second = await upload_selected_file( + Remote(), + SelectedFile("scanned-paper", source), + project_id="dome-performance", + state_store=state, + ) + + candidate = SourceRevisionRecord.model_validate_json( + json.dumps(first["source_candidate"]), strict=True + ) + assert candidate.archive_status == "archived" + assert candidate.artifact_sha256 == digest + assert len(candidate.revision_id) <= 128 + assert first["extraction_status"] == "unavailable" + assert first["extraction_error_code"] == "PDF_NO_TEXT" + assert second["source_candidate"] == first["source_candidate"] + assert calls.count("source_upload_begin") == 1 + assert calls.count("source_extract") == 2 + state_record = next(iter(json.loads((tmp_path / "state.json").read_text()).values())) + assert "completed_receipt" not in state_record + assert str(source) not in json.dumps(first) + + asyncio.run(exercise()) + + +def test_connector_rejects_cleartext_remote_service_urls() -> None: + with pytest.raises(ValueError, match="HTTPS"): + _remote_factory("http://gewu.example/mcp", "gwu_test") + _remote_factory("https://gewu.example/mcp", "gwu_test") + _remote_factory("http://localhost:8000/mcp", "gwu_test") + + +def test_remote_factory_verifies_service_certificate_before_authentication( + monkeypatch: pytest.MonkeyPatch, +) -> None: + from contextlib import asynccontextmanager + + import gewu.transports.stdio_connector as connector_module + + observed: dict[str, object] = {} + + class HttpClient: + def __init__(self, **kwargs: object) -> None: + observed.update(kwargs) + + async def __aenter__(self) -> object: + return self + + async def __aexit__(self, *args: object) -> None: + return None + + class RemoteClient: + def __init__(self, transport: object) -> None: + self.transport = transport + + async def __aenter__(self) -> object: + await self.transport.__aenter__() + return self + + async def __aexit__(self, *args: object) -> None: + await self.transport.__aexit__(*args) + return None + + @asynccontextmanager + async def transport_factory(url: str, *, http_client: object): + observed["url"] = url + observed["http_client"] = http_client + yield object() + + monkeypatch.setattr(connector_module.httpx2, "AsyncClient", HttpClient) + monkeypatch.setattr(connector_module, "Client", RemoteClient) + monkeypatch.setattr(connector_module, "streamable_http_client", transport_factory) + + async def exercise() -> None: + async with _remote_factory("https://gewu.example/mcp", "secret-token")() as remote: + assert isinstance(remote, RemoteClient) + + asyncio.run(exercise()) + assert observed["verify"] is True + assert observed["headers"] == {"Authorization": "Bearer secret-token"} + assert observed["url"] == "https://gewu.example/mcp" + assert "secret-token" not in str(observed["url"]) diff --git a/tests/application/__init__.py b/tests/application/__init__.py new file mode 100644 index 0000000..3271f75 --- /dev/null +++ b/tests/application/__init__.py @@ -0,0 +1 @@ +"""Application integration tests.""" diff --git a/tests/application/crash_acceptance_worker.py b/tests/application/crash_acceptance_worker.py new file mode 100644 index 0000000..ca30e90 --- /dev/null +++ b/tests/application/crash_acceptance_worker.py @@ -0,0 +1,29 @@ +"""Terminate acceptance at a durable publication boundary for recovery tests.""" + +from __future__ import annotations + +import os +import sys +from pathlib import Path + +from gewu.domain.contracts import ProposalRevision +from tests.application.test_acceptance import ACTOR, _service + + +def main() -> None: + data_root = Path(sys.argv[1]) + proposal_path = Path(sys.argv[2]) + crash_stage = sys.argv[3] + proposal = ProposalRevision.model_validate_json(proposal_path.read_bytes(), strict=True) + + def terminate(stage: str) -> None: + if stage == crash_stage: + os._exit(73) + + service, *_ = _service(data_root, hook=terminate) + service.submit(proposal, actor=ACTOR) + raise RuntimeError(f"Acceptance completed without reaching crash stage {crash_stage}") + + +if __name__ == "__main__": + main() diff --git a/tests/application/test_acceptance.py b/tests/application/test_acceptance.py new file mode 100644 index 0000000..fb85500 --- /dev/null +++ b/tests/application/test_acceptance.py @@ -0,0 +1,850 @@ +import hashlib +import json +import subprocess +import sys +import threading +from collections.abc import Callable +from concurrent.futures import ThreadPoolExecutor +from pathlib import Path + +import pyotp +import pytest +from cryptography.fernet import Fernet + +from gewu.adapters.git_repository import GitCanonicalRepository +from gewu.adapters.sqlite_identity import SQLiteIdentityStore +from gewu.adapters.sqlite_search import SQLiteSearchIndex +from gewu.adapters.text_extractor import TextMarkdownExtractor +from gewu.application.acceptance import ( + AcceptanceError, + AcceptanceService, + ActorContext, +) +from gewu.application.context import ValidationContext +from gewu.application.identity import Principal +from gewu.application.reading import CanonicalNodeReader +from gewu.application.search import CanonicalSearchService +from gewu.application.serialization import safe_yaml_record +from gewu.application.uploads import LocalArtifactStore +from gewu.domain.contracts import ( + AcceptanceManifest, + EdgeEndpoint, + ExtractionRecord, + KnowledgeNode, + ProposalOperation, + ProposalRevision, + ReliabilityGrade, + Scope, + SearchRequest, + SourceRevisionRecord, + TypedEdge, +) +from gewu.domain.validation import assertion_digest, evidence_digest, proposal_payload_digest +from tests.domain.proposal_fixtures import ( + EXTRACTION, + NODE, + POLICIES, + PROJECTS, + SOURCE_REVISION, + proposal, +) + +ACTOR = ActorContext(principal_id="agent-pilot", credential_id="agent-key-1") +SOURCE_BYTES = b"The installation hosted 400 visitors during the 2026 season." + + +class SimulatedProcessCrash(BaseException): + pass + + +def _service( + data_root: Path, + *, + hook: Callable[[str], None] = lambda _stage: None, + authorized: bool = True, +) -> tuple[ + AcceptanceService, + GitCanonicalRepository, + LocalArtifactStore, + SourceRevisionRecord, + ExtractionRecord, +]: + artifact_store = LocalArtifactStore(data_root / "private-data") + upload = artifact_store.begin( + filename="pilot.md", + media_type="text/markdown", + byte_length=len(SOURCE_BYTES), + expected_sha256=hashlib.sha256(SOURCE_BYTES).hexdigest(), + now=100, + ) + artifact_store.put_chunk( + upload.upload_id, + offset=0, + content=SOURCE_BYTES, + chunk_sha256=hashlib.sha256(SOURCE_BYTES).hexdigest(), + now=101, + ) + artifact_store.finalize(upload.upload_id, now=102) + source = SOURCE_REVISION.model_copy( + update={ + "artifact_sha256": hashlib.sha256(SOURCE_BYTES).hexdigest(), + "byte_length": len(SOURCE_BYTES), + } + ) + extraction = EXTRACTION + repository = GitCanonicalRepository(data_root / "private-data" / "knowledge.git") + context = ValidationContext( + projects=PROJECTS, + policies=POLICIES, + sources={}, + extractions={extraction.extraction_id: extraction}, + existing_nodes={}, + ) + service = AcceptanceService( + repository=repository, + artifact_store=artifact_store, + workflow_root=data_root / "private-data" / "staging", + context_provider=lambda: context, + actor_is_current=lambda _actor: authorized, + failure_hook=hook, + ) + return service, repository, artifact_store, source, extraction + + +def _proposal(repository: GitCanonicalRepository, source: SourceRevisionRecord) -> ProposalRevision: + candidate = proposal(source=source, base_commit=repository.head()) + return candidate.model_copy(update={"payload_digest": proposal_payload_digest(candidate)}) + + +def test_acceptance_commits_records_and_retry_returns_original_receipt(tmp_path: Path) -> None: + service, repository, _artifact_store, source, _extraction = _service(tmp_path) + candidate = _proposal(repository, source) + result = service.submit(candidate, actor=ACTOR) + + assert result.validation.eligible_for_auto_accept + assert result.receipt is not None + assert result.receipt.state == "accepted" + assert result.receipt.commit_id == repository.head() + assert result.receipt.accepted_node_ids == (NODE.node_id,) + before_retry = repository.head() + + retry = service.submit(candidate, actor=ACTOR) + assert retry.receipt == result.receipt + assert repository.head() == before_retry + status = service.proposal_status(candidate.proposal_id, candidate.revision, actor=ACTOR) + assert status.lifecycle == "accepted" + assert status.canonical_revision == result.receipt.commit_id + assert status.receipt == result.receipt + with pytest.raises(AcceptanceError) as accepted_withdrawal: + service.withdraw_proposal(candidate.proposal_id, candidate.revision, actor=ACTOR) + assert accepted_withdrawal.value.code == "PROPOSAL_ALREADY_ACCEPTED" + + with pytest.raises(AcceptanceError) as unauthorized: + service.proposal_status( + candidate.proposal_id, + candidate.revision, + actor=ActorContext(principal_id="agent-other", credential_id="other-key"), + ) + assert unauthorized.value.code == "PROPOSAL_NOT_FOUND" + + changed_node = NODE.model_copy(update={"title": "Changed after acceptance"}) + changed = proposal(node=changed_node, source=source, base_commit=candidate.base_commit) + with pytest.raises(AcceptanceError) as error: + service.submit(changed, actor=ACTOR) + assert error.value.code == "IDEMPOTENCY_CONFLICT" + + +def test_policy_exception_requires_exact_owner_decision_in_manifest(tmp_path: Path) -> None: + service, repository, _artifact_store, source, extraction = _service(tmp_path) + identities = SQLiteIdentityStore( + tmp_path / "owner-identity.sqlite3", owner_encryption_key=Fernet.generate_key() + ) + secret = pyotp.random_base32() + identities.provision_owner( + principal_id="project-owner", + password="correct horse battery staple", + totp_secret=secret, + project_ids=("dome-performance", "shared"), + ) + projects = { + **PROJECTS, + "dome-performance": PROJECTS["dome-performance"].model_copy( + update={"owners": ("project-owner",)} + ), + "shared": PROJECTS["dome-performance"].model_copy( + update={ + "project_id": "shared", + "title": "Shared knowledge", + "owners": ("project-owner",), + "autonomy_policy_id": "shared", + } + ), + } + policies = { + **POLICIES, + "dome-policy": POLICIES["dome-policy"].model_copy( + update={"automatic_node_types": ("finding",)} + ), + } + context = ValidationContext( + projects=projects, + policies=policies, + sources={}, + extractions={extraction.extraction_id: extraction}, + existing_nodes={}, + ) + service.context_provider = lambda: context + service.owner_challenge_issuer = identities.issue_owner_challenge + service.owner_decision_lookup = identities.authorized_owner_challenges + candidate = _proposal(repository, source) + + held = service.submit(candidate, actor=ACTOR) + assert held.validation.status == "needs-review" + assert held.receipt is None + assert len(held.owner_challenge_ids) == 1 + challenge = identities.get_owner_challenge( + held.owner_challenge_ids[0], principal_id="project-owner" + ) + assert challenge is not None + assert challenge["diff_json"] + owner_session = identities.authenticate_owner( + principal_id="project-owner", + password="correct horse battery staple", + totp_code=pyotp.TOTP(secret).now(), + challenge_id=held.owner_challenge_ids[0], + audience="gewu-acceptance-test", + ) + assert owner_session is not None + owner_principal = identities.authenticate_owner_session( + owner_session.session_token, + challenge_id=held.owner_challenge_ids[0], + audience="gewu-acceptance-test", + ) + assert owner_principal is not None + decision = identities.decide_owner_challenge( + challenge_id=held.owner_challenge_ids[0], + principal_id="project-owner", + proposal_revision=candidate.revision, + payload_digest=candidate.payload_digest, + diff_digest=challenge["diff_digest"], + decision="authorize", + rationale="Reviewed the exact file diff and approve this policy exception.", + owner_session_id=owner_principal.credential_id.rpartition(":")[2], + audience="gewu-acceptance-test", + ) + assert decision["status"] == "authorized" + + original_context = context + changed_policies = { + **policies, + "dome-policy": policies["dome-policy"].model_copy(update={"policy_version": 2}), + } + context = ValidationContext( + projects=projects, + policies=changed_policies, + sources={}, + extractions={extraction.extraction_id: extraction}, + existing_nodes={}, + ) + stale_approval = service.submit(candidate, actor=ACTOR) + assert stale_approval.receipt is None + assert stale_approval.validation.status == "needs-review" + assert repository.head() == candidate.base_commit + context = original_context + + accepted = service.submit(candidate, actor=ACTOR) + assert accepted.receipt is not None + assert accepted.validation.status == "needs-review" + raw_manifest = repository.read_file( + accepted.receipt.commit_id, + service._manifest_path(candidate.proposal_id, candidate.revision), + ) + assert raw_manifest is not None + manifest = safe_yaml_record(raw_manifest, AcceptanceManifest) + assert len(manifest.owner_decisions) == 1 + assert manifest.owner_decisions[0].challenge_id == held.owner_challenge_ids[0] + + +def test_contradiction_bundle_publishes_both_claims_and_their_typed_edge( + tmp_path: Path, +) -> None: + content = ( + b"Field report: The seasonal audience count was 400.\n\n" + b"Vendor report: The seasonal audience count was 450.\n" + ) + digest = hashlib.sha256(content).hexdigest() + artifact_store = LocalArtifactStore(tmp_path / "private-data") + upload = artifact_store.begin( + filename="conflicting-reports.md", + media_type="text/markdown", + byte_length=len(content), + expected_sha256=digest, + now=100, + ) + artifact_store.put_chunk( + upload.upload_id, + offset=0, + content=content, + chunk_sha256=digest, + now=101, + ) + artifact_store.finalize(upload.upload_id, now=102) + source = SOURCE_REVISION.model_copy( + update={"artifact_sha256": digest, "byte_length": len(content)} + ) + extraction = TextMarkdownExtractor().extract(source.revision_id, content, "text/markdown") + + def supported_node( + node_id: str, claim_id: str, quote: str, title: str, value: int + ) -> KnowledgeNode: + block = next(item for item in extraction.blocks if quote in item.normalized_text) + start = block.normalized_text.index(quote) + evidence = ( + NODE.claims[0] + .evidence[0] + .model_copy( + update={ + "source_revision_id": source.revision_id, + "extraction_id": extraction.extraction_id, + "assertion_digest": assertion_digest(quote), + "quote": quote, + "locator": NODE.claims[0] + .evidence[0] + .locator.model_copy( + update={ + "block_id": block.block_id, + "start": start, + "end": start + len(quote), + } + ), + } + ) + ) + assessment = NODE.claims[0].assessment.model_copy( + update={ + "assertion_digest": assertion_digest(quote), + "evidence_digests": (evidence_digest(evidence),), + } + ) + claim = NODE.claims[0].model_copy( + update={ + "claim_id": claim_id, + "text": quote, + "evidence": (evidence,), + "assessment": assessment, + } + ) + return NODE.model_copy( + update={ + "node_id": node_id, + "title": title, + "summary": quote, + "claims": (claim,), + "value": value, + } + ) + + first = supported_node( + "dome:count-field-report", + "dome:claim-count-400", + "The seasonal audience count was 400.", + "Field count", + 400, + ) + second = supported_node( + "dome:count-vendor-report", + "dome:claim-count-450", + "The seasonal audience count was 450.", + "Vendor count", + 450, + ) + edge = TypedEdge( + edge_id="dome:counts-conflict", + edge_type="contradicts", + source=EdgeEndpoint(kind="node", id=first.node_id), + target=EdgeEndpoint(kind="node", id=second.node_id), + conditions="Both reports refer to the same seasonal audience count.", + interpretation_rationale="The source-backed counts differ for the same reporting period.", + ) + policies = { + **POLICIES, + "dome-policy": POLICIES["dome-policy"].model_copy( + update={"automatic_edge_types": ("contradicts",)} + ), + } + repository = GitCanonicalRepository(tmp_path / "private-data" / "knowledge.git") + context = ValidationContext( + projects=PROJECTS, + policies=policies, + sources={}, + extractions={extraction.extraction_id: extraction}, + existing_nodes={}, + ) + service = AcceptanceService( + repository=repository, + artifact_store=artifact_store, + workflow_root=tmp_path / "private-data" / "staging", + context_provider=lambda: context, + actor_is_current=lambda _actor: True, + ) + base = proposal(node=None, source=source, base_commit=repository.head()) + candidate = base.model_copy( + update={ + "operations": ( + *base.operations, + ProposalOperation(operation="upsert_node", node=first), + ProposalOperation(operation="upsert_node", node=second), + ProposalOperation(operation="add_edge", edge=edge), + ), + "payload_digest": "0" * 64, + } + ) + candidate = candidate.model_copy(update={"payload_digest": proposal_payload_digest(candidate)}) + + result = service.submit(candidate, actor=ACTOR) + + assert result.validation.status == "eligible", result.validation.findings + assert result.receipt is not None + assert result.receipt.accepted_node_ids == (first.node_id, second.node_id) + for node in (first, second): + stored = repository.read_file( + result.receipt.commit_id, + f"projects/{hashlib.sha256(node.project_id.encode()).hexdigest()}/nodes/" + f"{hashlib.sha256(node.node_id.encode()).hexdigest()}.yaml", + ) + assert stored is not None + assert safe_yaml_record(stored, KnowledgeNode).claims[0].text == node.claims[0].text + stored_edge = repository.read_file( + result.receipt.commit_id, + f"edges/{hashlib.sha256(edge.edge_id.encode()).hexdigest()}.yaml", + ) + assert stored_edge is not None + assert safe_yaml_record(stored_edge, TypedEdge) == edge + + +def test_independent_acceptance_services_exclude_a_concurrent_writer(tmp_path: Path) -> None: + entered_commit = threading.Event() + release_commit = threading.Event() + + def hold_before_commit(stage: str) -> None: + if stage == "before_commit": + entered_commit.set() + assert release_commit.wait(timeout=5) + + service, repository, artifact_store, source, _extraction = _service( + tmp_path, hook=hold_before_commit + ) + second_context = ValidationContext( + projects=PROJECTS, + policies=POLICIES, + sources={}, + extractions={EXTRACTION.extraction_id: EXTRACTION}, + existing_nodes={}, + ) + second_service = AcceptanceService( + repository=repository, + artifact_store=artifact_store, + workflow_root=tmp_path / "private-data" / "staging", + context_provider=lambda: second_context, + actor_is_current=lambda _actor: True, + ) + first = _proposal(repository, source).model_copy(update={"proposal_id": "proposal-writer-a"}) + second = _proposal(repository, source).model_copy(update={"proposal_id": "proposal-writer-b"}) + + with ThreadPoolExecutor(max_workers=1) as workers: + pending = workers.submit(service.submit, first, actor=ACTOR) + try: + assert entered_commit.wait(timeout=5) + with pytest.raises(AcceptanceError) as busy: + second_service.submit(second, actor=ACTOR) + assert busy.value.code == "BUSY" + finally: + release_commit.set() + accepted = pending.result(timeout=5) + + assert accepted.receipt is not None + assert repository.head() == accepted.receipt.commit_id + assert second_service.receipt(second.proposal_id, second.revision) is None + + +def test_acceptance_publishes_and_reads_evidence_backed_assumptions(tmp_path: Path) -> None: + service, repository, _artifact_store, source, extraction = _service(tmp_path) + original_claim = NODE.claims[0] + statement = "The installation's seasonal count is suitable for comparison." + statement_digest = assertion_digest(statement) + evidence = original_claim.evidence[0].model_copy( + update={ + "assertion_path": "assumptions[0].text", + "assertion_digest": statement_digest, + } + ) + claim = original_claim.model_copy( + update={ + "claim_id": "dome:assumption-seasonal-comparison", + "text": statement, + "evidence": (evidence,), + "assessment": original_claim.assessment.model_copy( + update={ + "assertion_digest": statement_digest, + "evidence_digests": (evidence_digest(evidence),), + } + ), + } + ) + node = NODE.model_copy(update={"assumptions": (claim,)}) + candidate = proposal(node=node, source=source, base_commit=repository.head()) + candidate = candidate.model_copy(update={"payload_digest": proposal_payload_digest(candidate)}) + + result = service.submit( + candidate, + actor=ACTOR, + staged_extractions={extraction.extraction_id: extraction}, + ) + + assert result.receipt is not None + assert ( + repository.read_file( + result.receipt.commit_id, f"extractions/{extraction.extraction_id}.json" + ) + is not None + ) + reader = CanonicalNodeReader(repository) + principal = Principal( + "reader", "reader-key", frozenset({"viewer"}), frozenset({"dome-performance"}) + ) + read = reader.get_node(node.project_id, node.node_id, principal=principal) + stored = read.data[0] + assert stored["assumptions"][0]["text"] == statement + assert any(item.assertion_digest == statement_digest for item in read.citations) + + +def test_reference_only_source_registers_without_an_artifact(tmp_path: Path) -> None: + service, repository, _artifacts, _source, _extraction = _service(tmp_path) + reference = SourceRevisionRecord( + source_id="dome:missing-paper", + revision_id="dome:missing-paper:r1", + media_type="application/octet-stream", + byte_length=0, + captured_at=SOURCE_REVISION.captured_at, + original_url="https://research.example/missing-paper", + title="Paper whose archive is unavailable", + license="unspecified", + source_type="paper", + reliability_grade=ReliabilityGrade.MEDIUM, + ingested_for=("dome-performance",), + archive_status="capture-failed", + ) + candidate = ProposalRevision( + proposal_id="proposal-reference-only", + revision=1, + initiating_project_id="dome-performance", + scope=proposal().scope, + base_commit=repository.head(), + payload_digest="0" * 64, + proposer_id=ACTOR.principal_id, + run_id="run-reference-only", + operations=(ProposalOperation(operation="register_source", source=reference),), + ) + candidate = candidate.model_copy(update={"payload_digest": proposal_payload_digest(candidate)}) + + result = service.submit(candidate, actor=ACTOR) + + assert result.receipt is not None + assert result.receipt.state == "accepted" + paths = repository.list_files(repository.head(), "sources") + stored = next(repository.read_file(repository.head(), path) for path in paths) + assert stored is not None + assert b"capture-failed" in stored + assert b"artifact_sha256: null" in stored + + +@pytest.mark.parametrize("crash_stage", ("after_ref_update", "before_receipt")) +def test_ref_update_before_receipt_is_reconciled_without_republishing( + tmp_path: Path, crash_stage: str +) -> None: + def fail_after_ref_update(stage: str) -> None: + if stage == crash_stage: + raise SimulatedProcessCrash + + service, repository, _store, source, _extraction = _service( + tmp_path, hook=fail_after_ref_update + ) + candidate = _proposal(repository, source) + with pytest.raises(SimulatedProcessCrash): + service.submit(candidate, actor=ACTOR) + accepted_head = repository.head() + + recovered, _same_repo, _same_store, _same_source, _same_extraction = ( + _service_without_new_artifact(service, repository, tmp_path, source) + ) + recovered.recover() + receipt = recovered.receipt(candidate.proposal_id, candidate.revision) + assert receipt is not None + assert receipt.commit_id == accepted_head + assert repository.head() == accepted_head + status = recovered.proposal_status(candidate.proposal_id, candidate.revision, actor=ACTOR) + assert status.lifecycle == "accepted" + assert status.receipt == receipt + retry = recovered.submit(candidate, actor=ACTOR) + assert retry.receipt == receipt + + +@pytest.mark.parametrize("crash_stage", ("after_ref_update", "before_receipt")) +def test_os_process_termination_recovers_publication_after_restart( + tmp_path: Path, crash_stage: str +) -> None: + service, _repository, _store, source, _extraction = _service(tmp_path) + candidate = _proposal(service.repository, source) + proposal_path = tmp_path / "candidate.json" + proposal_path.write_bytes(candidate.model_dump_json().encode("utf-8")) + + crashed = subprocess.run( + [ + sys.executable, + "-m", + "tests.application.crash_acceptance_worker", + str(tmp_path), + str(proposal_path), + crash_stage, + ], + cwd=Path(__file__).parents[2], + capture_output=True, + check=False, + timeout=60, + ) + assert crashed.returncode == 73, crashed.stderr.decode("utf-8", errors="replace") + + # Construct fresh adapters as a new service process would, then recover from durable state. + recovered, repository, _store, _source, _extraction = _service(tmp_path) + committed_head = repository.head() + recovered.recover() + receipt = recovered.receipt(candidate.proposal_id, candidate.revision) + assert receipt is not None + assert receipt.commit_id == committed_head + status = recovered.proposal_status(candidate.proposal_id, candidate.revision, actor=ACTOR) + assert status.lifecycle == "accepted" + assert status.receipt == receipt + + # The derived index was absent at the crash. Its first read after recovery must + # rebuild from the committed revision and retain the accepted claim's citation. + index = SQLiteSearchIndex(tmp_path / "private-data" / "search.sqlite3", repository) + assert index.diagnostic_status()[0] is None + reader = CanonicalSearchService( + index=index, + project_ids_provider=lambda: PROJECTS.keys(), + canonical_head=repository.head, + ) + search_result = reader.search( + SearchRequest( + query="visitors", + scope=Scope(projects=("dome-performance",), include_shared=False), + budget_tokens=5000, + ), + principal=Principal( + "recovery-reader", + "recovery-reader-key", + frozenset({"viewer"}), + frozenset({"dome-performance"}), + ), + ) + assert search_result.data + indexed_node = search_result.data[0]["node"] + assert isinstance(indexed_node, dict) + assert indexed_node["node_id"] == NODE.node_id + assert search_result.citations + + retry = recovered.submit(candidate, actor=ACTOR) + assert retry.receipt == receipt + assert repository.head() == committed_head + + +def test_prepublication_crash_leaves_proposal_staged_and_retryable(tmp_path: Path) -> None: + def fail_before_commit(stage: str) -> None: + if stage == "before_commit": + raise SimulatedProcessCrash + + service, repository, _store, source, _extraction = _service(tmp_path, hook=fail_before_commit) + candidate = _proposal(repository, source) + base = repository.head() + with pytest.raises(SimulatedProcessCrash): + service.submit(candidate, actor=ACTOR) + assert repository.head() == base + + recovered, *_ = _service_without_new_artifact(service, repository, tmp_path, source) + recovered.recover() + status = recovered.proposal_status(candidate.proposal_id, candidate.revision, actor=ACTOR) + assert status.lifecycle == "staged" + retry = recovered.submit(candidate, actor=ACTOR) + assert retry.receipt is not None + assert repository.head() == retry.receipt.commit_id + + +def test_unrelated_submission_waits_until_pending_publication_is_reconciled( + tmp_path: Path, +) -> None: + crash_pending_writer = True + + def fail_after_ref_update(stage: str) -> None: + nonlocal crash_pending_writer + if stage == "after_ref_update" and crash_pending_writer: + crash_pending_writer = False + raise SimulatedProcessCrash + + service, repository, _store, source, _extraction = _service( + tmp_path, hook=fail_after_ref_update + ) + pending = _proposal(repository, source) + with pytest.raises(SimulatedProcessCrash): + service.submit(pending, actor=ACTOR) + committed_head = repository.head() + + journal_path = sorted((tmp_path / "private-data/staging/journal").glob("*/*.json"))[-1] + journal_entry = json.loads(journal_path.read_text(encoding="utf-8")) + expected_manifest_hash = journal_entry["manifest_sha256"] + journal_entry["manifest_sha256"] = "0" * 64 + journal_path.write_text(json.dumps(journal_entry), encoding="utf-8") + + followup_node = NODE.model_copy( + update={"node_id": "dome:follow-up-finding", "title": "Follow-up visitor finding"} + ) + followup = proposal( + node=followup_node, + source=source, + base_commit=committed_head, + ).model_copy(update={"proposal_id": "proposal-pilot-2"}) + followup = followup.model_copy(update={"payload_digest": proposal_payload_digest(followup)}) + + with pytest.raises(AcceptanceError) as blocked: + service.submit(followup, actor=ACTOR) + assert blocked.value.code == "RECOVERY_REQUIRED" + assert repository.head() == committed_head + assert not service._proposal_path(followup.proposal_id, followup.revision).exists() + + journal_entry["manifest_sha256"] = expected_manifest_hash + journal_path.write_text(json.dumps(journal_entry), encoding="utf-8") + service.recover() + pending_receipt = service.receipt(pending.proposal_id, pending.revision) + assert pending_receipt is not None + assert pending_receipt.commit_id == committed_head + + completed = service.submit(followup, actor=ACTOR) + assert completed.receipt is not None + assert completed.receipt.commit_id == repository.head() + assert repository.head() != committed_head + + +def test_proposal_withdrawal_is_idempotent_and_prevents_later_submission(tmp_path: Path) -> None: + service, repository, _store, source, _extraction = _service(tmp_path) + initial_head = repository.head() + candidate = _proposal(repository, source).model_copy(update={"base_commit": "f" * 40}) + candidate = candidate.model_copy(update={"payload_digest": "0" * 64}) + candidate = candidate.model_copy(update={"payload_digest": proposal_payload_digest(candidate)}) + evaluated = service.submit(candidate, actor=ACTOR) + assert evaluated.validation.status == "conflict" + assert evaluated.receipt is None + + withdrawn = service.withdraw_proposal(candidate.proposal_id, candidate.revision, actor=ACTOR) + assert withdrawn.lifecycle == "withdrawn" + assert ( + service.withdraw_proposal(candidate.proposal_id, candidate.revision, actor=ACTOR) + == withdrawn + ) + assert ( + service.proposal_status(candidate.proposal_id, candidate.revision, actor=ACTOR) == withdrawn + ) + with pytest.raises(AcceptanceError) as error: + service.submit(candidate, actor=ACTOR) + assert error.value.code == "PROPOSAL_WITHDRAWN" + assert repository.head() == initial_head + + +def _service_without_new_artifact( + old_service: AcceptanceService, + repository: GitCanonicalRepository, + data_root: Path, + source: SourceRevisionRecord, +) -> tuple[ + AcceptanceService, + GitCanonicalRepository, + LocalArtifactStore, + SourceRevisionRecord, + ExtractionRecord, +]: + from tests.domain.proposal_fixtures import EXTRACTION + + store = old_service.artifact_store + context = ValidationContext( + projects=PROJECTS, + policies=POLICIES, + sources={}, + extractions={EXTRACTION.extraction_id: EXTRACTION}, + existing_nodes={}, + ) + service = AcceptanceService( + repository=repository, + artifact_store=store, + workflow_root=data_root / "private-data" / "staging", + context_provider=lambda: context, + actor_is_current=lambda _actor: True, + ) + return service, repository, store, source, EXTRACTION + + +def test_changed_authority_blocks_publication(tmp_path: Path) -> None: + service, repository, _store, source, _extraction = _service(tmp_path, authorized=False) + candidate = _proposal(repository, source) + with pytest.raises(AcceptanceError) as error: + service.submit(candidate, actor=ACTOR) + assert error.value.code == "UNAUTHORIZED" + assert repository.head() == candidate.base_commit + + +def test_revoked_key_during_validation_is_rechecked_before_publication(tmp_path: Path) -> None: + service, repository, _store, source, _extraction = _service(tmp_path) + identities = SQLiteIdentityStore(tmp_path / "identity.sqlite3") + token = identities.provision( + credential_id="agent-key-1", + principal_id="agent-pilot", + roles=("agent",), + project_ids=("dome-performance", "shared"), + ) + principal = identities.authenticate(token) + assert principal is not None + actor = ActorContext( + principal_id=principal.principal_id, + credential_id=principal.credential_id, + roles=principal.roles, + project_ids=principal.project_ids, + ) + context = ValidationContext( + projects=PROJECTS, + policies=POLICIES, + sources={}, + extractions={_extraction.extraction_id: _extraction}, + existing_nodes={}, + ) + context_reads = 0 + + def context_provider() -> ValidationContext: + nonlocal context_reads + context_reads += 1 + if context_reads == 2: + assert identities.revoke("agent-key-1") + return context + + service.context_provider = context_provider + service.actor_is_current = lambda candidate: identities.is_current( + candidate.principal_id, + candidate.credential_id, + roles=candidate.roles, + project_ids=candidate.project_ids, + ) + candidate = _proposal(repository, source) + + with pytest.raises(AcceptanceError) as error: + service.submit(candidate, actor=actor) + assert error.value.code == "POLICY_CHANGED" + assert repository.head() == candidate.base_commit + status = service.proposal_status(candidate.proposal_id, candidate.revision, actor=actor) + assert status.lifecycle == "evaluated" + assert status.receipt is None diff --git a/tests/application/test_hybrid_search.py b/tests/application/test_hybrid_search.py new file mode 100644 index 0000000..8a319ad --- /dev/null +++ b/tests/application/test_hybrid_search.py @@ -0,0 +1,305 @@ +from __future__ import annotations + +import hashlib +import sqlite3 +from pathlib import Path + +import pytest +import yaml + +from gewu.adapters.git_repository import GitCanonicalRepository +from gewu.adapters.sqlite_search import SearchIndexError, SQLiteSearchIndex +from gewu.adapters.sqlite_vectors import SQLiteVectorIndex +from gewu.application.identity import Principal +from gewu.application.search import CanonicalSearchService, HybridSearchIndex, SearchError +from gewu.domain.contracts import ( + Confidence, + EdgeEndpoint, + NodeType, + Scope, + SearchRequest, + TypedEdge, +) +from tests.domain.proposal_fixtures import NODE + + +class FixtureEmbeddings: + dimensions = 2 + model_id = "fixture-e5@v1" + artifact_sha256 = "b" * 64 + + def embed_queries(self, texts: tuple[str, ...]) -> tuple[tuple[float, ...], ...]: + return tuple((1.0, 0.0) for _ in texts) + + def embed_passages(self, texts: tuple[str, ...]) -> tuple[tuple[float, ...], ...]: + return tuple( + (1.0, 0.0) if text.startswith("Semantic match") else (0.0, 1.0) for text in texts + ) + + +def _record_path(node_id: str, project_id: str) -> str: + project_key = hashlib.sha256(project_id.encode()).hexdigest() + node_key = hashlib.sha256(node_id.encode()).hexdigest() + return f"projects/{project_key}/nodes/{node_key}.yaml" + + +def _repository(tmp_path: Path) -> GitCanonicalRepository: + repository = GitCanonicalRepository(tmp_path / "knowledge.git") + nodes = [ + NODE.model_copy( + update={ + "node_id": f"dome:alpha-{number:02}", + "title": f"Alpha candidate {number:02}", + "summary": f"Alpha lexical candidate {number:02}.", + "node_type": NodeType.FINDING, + "confidence": Confidence.MEDIUM, + } + ) + for number in range(12) + ] + nodes.extend( + ( + NODE.model_copy( + update={ + "node_id": "dome:semantic-match", + "title": "Semantic match for the intent", + "summary": "A close meaning with no shared query word.", + "node_type": NodeType.TECHNIQUE, + "confidence": Confidence.HIGH, + } + ), + NODE.model_copy( + update={ + "node_id": "dome:graph-related", + "title": "Connected nonlexical technique", + "summary": "A related result found through one typed edge.", + "node_type": NodeType.TECHNIQUE, + "confidence": Confidence.MEDIUM, + } + ), + NODE.model_copy( + update={ + "node_id": "video:semantic-match", + "project_id": "interactive-video", + "title": "Semantic match in another project", + "summary": "Must remain outside the requested project scope.", + "node_type": NodeType.TECHNIQUE, + "confidence": Confidence.HIGH, + } + ), + ) + ) + edges = ( + TypedEdge( + edge_id="dome:search-expansion-edge", + edge_type="compared_with", + source=EdgeEndpoint(kind="node", id="dome:alpha-00"), + target=EdgeEndpoint(kind="node", id="dome:graph-related"), + interpretation_rationale="Both notes inform the same audience-attendance question.", + ), + TypedEdge( + edge_id="dome:search-cross-project-edge", + edge_type="compared_with", + source=EdgeEndpoint(kind="node", id="dome:alpha-00"), + target=EdgeEndpoint(kind="node", id="video:semantic-match"), + interpretation_rationale="Cross-project edge must not widen search scope.", + ), + ) + files = { + _record_path(node.node_id, node.project_id): yaml.safe_dump( + node.model_dump(mode="json") + ).encode() + for node in nodes + } + files.update( + { + f"edges/{hashlib.sha256(edge.edge_id.encode()).hexdigest()}.yaml": yaml.safe_dump( + edge.model_dump(mode="json") + ).encode() + for edge in edges + } + ) + repository.publish_files( + expected_head=repository.head(), + files=files, + message="Add hybrid search fixtures", + index_directory=tmp_path / "publishing", + ) + return repository + + +def test_hybrid_search_fuses_ranks_and_preserves_scope_and_filters( + tmp_path: Path, monkeypatch +) -> None: + from gewu.application import search as search_module + + monkeypatch.setattr(search_module, "_VECTOR_CANDIDATE_LIMIT", 1) + repository = _repository(tmp_path) + search_path = tmp_path / "search.sqlite3" + lexical = SQLiteSearchIndex(search_path, repository) + vectors = SQLiteVectorIndex(search_path) + hybrid = HybridSearchIndex( + lexical=lexical, + vectors=vectors, + embeddings=FixtureEmbeddings(), + ) + lexical.configure_vector_generation( + extension_loader=vectors.load_extension, + builder=hybrid.build_vector_generation, + is_current=hybrid.vector_generation_is_current, + ) + service = CanonicalSearchService( + index=hybrid, + project_ids_provider=lambda: ("dome-performance", "interactive-video"), + canonical_head=repository.head, + ) + principal = Principal( + "reader", "reader-key", frozenset({"viewer"}), frozenset({"dome-performance"}) + ) + request = SearchRequest( + query="alpha", + scope=Scope(projects=("dome-performance",), include_shared=False), + limit=20, + budget_tokens=50_000, + ) + + response = service.search(request, principal=principal) + first_generation = vectors.active_generation() + assert first_generation is not None + assert lexical.path == vectors.path + assert first_generation.revision == response.index_revision + assert first_generation.node_count == 15 + result_ids = [item["node"]["node_id"] for item in response.data] + assert response.retrieval_mode == "hybrid" + assert result_ids.index("dome:semantic-match") < result_ids.index("dome:alpha-11") + graph_hit = next( + item for item in response.data if item["node"]["node_id"] == "dome:graph-related" + ) + assert graph_hit["retrieval_path"] == "graph_expansion" + assert graph_hit["expanded_from"] == "dome:alpha-00" + assert graph_hit["expansion_edge"]["edge_type"] == "compared_with" + assert not any(node_id.startswith("video:") for node_id in result_ids) + assert all("retrieval_score" in item for item in response.data) + + first_page = service.search(request.model_copy(update={"limit": 5}), principal=principal) + assert first_page.next_cursor is not None + second_page = service.search( + request.model_copy(update={"limit": 5, "cursor": first_page.next_cursor}), + principal=principal, + ) + first_ids = {item["node"]["node_id"] for item in first_page.data} + second_ids = {item["node"]["node_id"] for item in second_page.data} + assert len(first_ids) == len(second_ids) == 5 + assert first_ids.isdisjoint(second_ids) + + class ChangedModelEmbeddings(FixtureEmbeddings): + model_id = "fixture-e5@v2" + + changed_hybrid = HybridSearchIndex( + lexical=lexical, + vectors=vectors, + embeddings=ChangedModelEmbeddings(), + ) + changed_profile_service = CanonicalSearchService( + index=changed_hybrid, + project_ids_provider=lambda: ("dome-performance", "interactive-video"), + canonical_head=repository.head, + ) + with pytest.raises(SearchError, match="cursor is invalid"): + changed_profile_service.search( + request.model_copy(update={"limit": 5, "cursor": first_page.next_cursor}), + principal=principal, + ) + + filtered = service.search( + request.model_copy( + update={"node_types": (NodeType.TECHNIQUE,), "confidences": (Confidence.HIGH,)} + ), + principal=principal, + ) + assert [item["node"]["node_id"] for item in filtered.data] == ["dome:semantic-match"] + assert filtered.retrieval_mode == "hybrid" + + lexical.configure_vector_generation( + extension_loader=vectors.load_extension, + builder=changed_hybrid.build_vector_generation, + is_current=changed_hybrid.vector_generation_is_current, + ) + changed_response = changed_profile_service.search( + request.model_copy(update={"limit": 5}), principal=principal + ) + changed_generation = vectors.active_generation() + assert changed_response.retrieval_mode == "hybrid" + assert changed_generation is not None and changed_generation.model_id == "fixture-e5@v2" + assert vectors.generation(first_generation.generation_id) is not None + + class FailingEmbeddings(FixtureEmbeddings): + model_id = "fixture-e5@failed-build" + + def embed_passages(self, texts: tuple[str, ...]) -> tuple[tuple[float, ...], ...]: + raise RuntimeError("fixture model failure") + + failing_hybrid = HybridSearchIndex( + lexical=lexical, + vectors=vectors, + embeddings=FailingEmbeddings(), + ) + lexical.configure_vector_generation( + extension_loader=vectors.load_extension, + builder=failing_hybrid.build_vector_generation, + is_current=failing_hybrid.vector_generation_is_current, + ) + degraded = CanonicalSearchService( + index=failing_hybrid, + project_ids_provider=lambda: ("dome-performance", "interactive-video"), + canonical_head=repository.head, + ).search(request.model_copy(update={"limit": 5}), principal=principal) + assert degraded.retrieval_mode == "keyword" + assert any(item.code == "HYBRID_RETRIEVAL_DEGRADED" for item in degraded.warnings) + assert degraded.next_cursor is None + + +def test_fts_and_vector_publication_roll_back_as_one_transaction(tmp_path: Path) -> None: + repository = _repository(tmp_path) + search_path = tmp_path / "search.sqlite3" + lexical = SQLiteSearchIndex(search_path, repository) + vectors = SQLiteVectorIndex(search_path) + hybrid = HybridSearchIndex( + lexical=lexical, + vectors=vectors, + embeddings=FixtureEmbeddings(), + ) + + def fail_after_vector_publish(connection, revision, nodes) -> None: + hybrid.build_vector_generation(connection, revision, nodes) + raise sqlite3.OperationalError("simulated termination before index commit") + + lexical.configure_vector_generation( + extension_loader=vectors.load_extension, + builder=fail_after_vector_publish, + is_current=hybrid.vector_generation_is_current, + ) + with pytest.raises(SearchIndexError): + lexical.search( + query="alpha", + project_ids=("dome-performance",), + include_shared=False, + limit=5, + ) + assert lexical.diagnostic_status()[0] is None + assert vectors.active_generation() is None + + lexical.configure_vector_generation( + extension_loader=vectors.load_extension, + builder=hybrid.build_vector_generation, + is_current=hybrid.vector_generation_is_current, + ) + recovered = hybrid.search( + query="alpha", + project_ids=("dome-performance",), + include_shared=False, + limit=5, + ) + active = vectors.active_generation() + assert recovered.retrieval_mode == "hybrid" + assert active is not None and active.revision == recovered.index_revision diff --git a/tests/application/test_registry.py b/tests/application/test_registry.py new file mode 100644 index 0000000..a7f7b17 --- /dev/null +++ b/tests/application/test_registry.py @@ -0,0 +1,165 @@ +import hashlib +import json +import subprocess +import sys +from pathlib import Path + +import portalocker +import pytest +import yaml + +from gewu.adapters.git_repository import GitCanonicalRepository +from gewu.application.registry import LocalContextProvider, LocalProjectRegistry +from gewu.domain.contracts import AutoAcceptPolicy, ProjectRecord, ProjectStatus +from gewu.domain.validation import extraction_digest +from tests.domain.proposal_fixtures import EXTRACTION, NODE, POLICIES, PROJECTS + + +def test_project_registry_create_and_archive_are_atomic_and_idempotent(tmp_path: Path) -> None: + registry_file = tmp_path / "control" / "registry.json" + registry = LocalProjectRegistry( + registry_file, + repository=GitCanonicalRepository(tmp_path / "knowledge.git"), + index_directory=tmp_path / "publishing", + authority_lock_path=tmp_path / "workflow" / "locks" / "canonical-writer.lock", + ) + project = ProjectRecord( + project_id="new-research", + title="New research", + status=ProjectStatus.ACTIVE, + owners=("owner-one",), + north_star="Investigate a new research area.", + key_questions=("What is known?",), + autonomy_policy_id="new-policy", + default_includes=("shared",), + ) + policy = AutoAcceptPolicy( + policy_id="new-policy", + policy_version=1, + project_id="new-research", + require_supported_assessment=True, + ) + + assert registry.create(project, policy) == project + assert registry.repository is not None + revision_after_create = registry.repository.head() + assert ( + registry.repository.read_file(revision_after_create, "projects/new-research/project.md") + is not None + ) + assert registry.repository.list_files(revision_after_create, "policies") + canonical_context = LocalContextProvider(registry.repository, registry_file)() + assert canonical_context.projects["new-research"] == project + assert canonical_context.policies["new-policy"] == policy + assert registry.records() == (project,) + assert "new-research" in registry() + lock_path = tmp_path / "workflow" / "locks" / "canonical-writer.lock" + with portalocker.Lock(lock_path, mode="a", timeout=0): + with pytest.raises(portalocker.exceptions.LockException): + registry.archive("new-research") + assert registry.archive("new-research").status == ProjectStatus.ARCHIVED + assert registry.repository.head() != revision_after_create + assert registry.archive("new-research").status == ProjectStatus.ARCHIVED + assert registry.records()[0].status == ProjectStatus.ARCHIVED + assert ( + LocalContextProvider(registry.repository, registry_file)().projects["new-research"].status + == ProjectStatus.ARCHIVED + ) + + +def test_archive_migrates_legacy_project_to_canonical_registry(tmp_path: Path) -> None: + registry_file = tmp_path / "control" / "registry.json" + registry_file.parent.mkdir(parents=True) + registry_file.write_text( + json.dumps( + { + "schema_version": 1, + "projects": [project.model_dump(mode="json") for project in PROJECTS.values()], + "policies": [policy.model_dump(mode="json") for policy in POLICIES.values()], + } + ), + encoding="utf-8", + ) + repository = GitCanonicalRepository(tmp_path / "knowledge.git") + registry = LocalProjectRegistry( + registry_file, + repository=repository, + index_directory=tmp_path / "publishing", + authority_lock_path=tmp_path / "workflow" / "locks" / "canonical-writer.lock", + ) + + archived = registry.archive("dome-performance") + context = LocalContextProvider(repository, registry_file)() + + assert archived.status == ProjectStatus.ARCHIVED + assert context.projects["dome-performance"].status == ProjectStatus.ARCHIVED + assert context.projects["interactive-video"] == PROJECTS["interactive-video"] + assert context.policies == POLICIES + + +def test_canonical_registry_retains_prior_extractions_after_parser_replacement( + tmp_path: Path, +) -> None: + repository = GitCanonicalRepository(tmp_path / "knowledge.git") + prior = EXTRACTION + replacement_candidate = prior.model_copy( + update={"extraction_id": "0" * 64, "parser_version": "replacement-parser-1"} + ) + replacement = replacement_candidate.model_copy( + update={"extraction_id": extraction_digest(replacement_candidate)} + ) + + node_path = ( + f"projects/{hashlib.sha256(NODE.project_id.encode()).hexdigest()}/nodes/" + f"{hashlib.sha256(NODE.node_id.encode()).hexdigest()}.yaml" + ) + first_revision = repository.publish_files( + expected_head=repository.head(), + files={ + f"extractions/{prior.extraction_id}.json": prior.model_dump_json().encode(), + node_path: yaml.safe_dump(NODE.model_dump(mode="json")).encode(), + }, + message="Publish a cited node using the original parser extraction", + index_directory=tmp_path / "publishing", + ) + replacement_revision = repository.publish_files( + expected_head=repository.head(), + files={ + f"extractions/{replacement.extraction_id}.json": replacement.model_dump_json().encode() + }, + message="Add replacement parser extraction without deleting history", + index_directory=tmp_path / "publishing", + ) + + context = LocalContextProvider(repository, tmp_path / "control/registry.json")() + assert context.extractions[prior.extraction_id] == prior + assert context.extractions[replacement.extraction_id] == replacement + assert context.existing_nodes[NODE.node_id] == NODE + assert ( + context.existing_nodes[NODE.node_id].claims[0].evidence[0].extraction_id + == prior.extraction_id + ) + assert ( + repository.read_file(first_revision, f"extractions/{prior.extraction_id}.json") is not None + ) + assert ( + repository.read_file(replacement_revision, f"extractions/{prior.extraction_id}.json") + is not None + ) + + +def test_disposable_registry_seed_loads_with_versioned_contracts( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setenv("GEWU_DATA_ROOT", str(tmp_path)) + result = subprocess.run( + [sys.executable, "scripts/seed_test_registry.py"], + check=False, + capture_output=True, + text=True, + ) + assert result.returncode == 0, result.stderr + repository = GitCanonicalRepository(tmp_path / "knowledge.git") + context = LocalContextProvider(repository, tmp_path / "control/registry.json")() + assert set(context.projects) == {"dome-performance", "interactive-video"} + assert set(context.policies) == {"dome-pilot-policy", "video-pilot-policy", "shared"} diff --git a/tests/application/test_runs.py b/tests/application/test_runs.py new file mode 100644 index 0000000..f370546 --- /dev/null +++ b/tests/application/test_runs.py @@ -0,0 +1,106 @@ +from pathlib import Path + +import pytest +from pydantic import ValidationError + +from gewu.application.acceptance import ActorContext +from gewu.application.runs import LocalRunService, RunError +from gewu.domain.contracts import ( + RunCheckpointRequest, + RunCreateRequest, + RunModulePin, + RunState, + Scope, +) + + +def _create(run_id: str = "run-pilot") -> RunCreateRequest: + return RunCreateRequest( + run_id=run_id, + objective="Compare audience response", + scope=Scope(projects=("dome-performance",), include_shared=False), + lens="Audience behavior", + pack_id="gewu", + pack_version="0.3.0", + pack_snapshot_digest="a" * 64, + module_pins=( + RunModulePin( + module_id="profile", + implementation_version="1.0.0", + sha256="b" * 64, + ), + ), + ) + + +def _checkpoint(run_id: str = "run-pilot", **overrides: object) -> RunCheckpointRequest: + values: dict[str, object] = { + "run_id": run_id, + "expected_revision": 0, + "stage": "source-review", + "state": RunState.ACTIVE, + "budgets": {"source_count": 3}, + } + values.update(overrides) + return RunCheckpointRequest.model_validate(values) + + +def test_run_create_and_checkpoint_are_idempotent_and_revision_checked(tmp_path: Path) -> None: + current = True + actor = ActorContext("agent-pilot", "pilot-key") + service = LocalRunService( + workflow_root=tmp_path / "workflow", + actor_is_current=lambda _: current, + ) + + created = service.create(_create(), actor=actor) + assert created.revision == 0 + assert service.create(_create(), actor=actor) == created + with pytest.raises(RunError, match="different input"): + service.create(_create().model_copy(update={"objective": "Different"}), actor=actor) + + checkpoint = _checkpoint() + updated = service.checkpoint(checkpoint, actor=actor) + assert updated.revision == 1 + assert service.checkpoint(checkpoint, actor=actor) == updated + with pytest.raises(RunError, match="changed content"): + service.checkpoint(_checkpoint(stage="different-stage"), actor=actor) + service.checkpoint(_checkpoint(expected_revision=1, stage="next-stage"), actor=actor) + with pytest.raises(RunError, match="stale"): + service.checkpoint(_checkpoint(expected_revision=0, stage="different-stage"), actor=actor) + assert service.status("run-pilot", actor=actor).revision == 2 + + +def test_run_owner_revocation_and_terminal_state_are_enforced(tmp_path: Path) -> None: + active = True + owner = ActorContext("agent-pilot", "pilot-key") + other = ActorContext("agent-other", "other-key") + service = LocalRunService( + workflow_root=tmp_path / "workflow", + actor_is_current=lambda actor: active and actor.credential_id in {"pilot-key", "other-key"}, + ) + service.create(_create(), actor=owner) + with pytest.raises(RunError, match="unavailable"): + service.status("run-pilot", actor=other) + + completed = service.checkpoint(_checkpoint(state=RunState.COMPLETED), actor=owner) + assert completed.state is RunState.COMPLETED + with pytest.raises(RunError, match="cannot be updated"): + service.checkpoint(_checkpoint(expected_revision=1), actor=owner) + + active = False + with pytest.raises(RunError, match="no longer active"): + service.checkpoint(_checkpoint(expected_revision=1), actor=owner) + with pytest.raises(RunError, match="no longer active"): + service.status("run-pilot", actor=owner) + + +def test_run_contract_rejects_global_scope_and_negative_budgets() -> None: + with pytest.raises(ValidationError): + RunCreateRequest.model_validate( + _create().model_dump() | {"scope": Scope(projects=("all",), all_projects=True)} + ) + with pytest.raises(ValidationError): + RunCheckpointRequest.model_validate( + {"run_id": "run-pilot", "expected_revision": 0, "stage": "x", "budgets": {"tokens": -1}} + ) diff --git a/tests/application/test_search_scope.py b/tests/application/test_search_scope.py new file mode 100644 index 0000000..fed35e6 --- /dev/null +++ b/tests/application/test_search_scope.py @@ -0,0 +1,84 @@ +import json +from pathlib import Path +from typing import cast + +import pytest + +from gewu.application.identity import Principal +from gewu.application.registry import LocalProjectRegistry +from gewu.application.search import CanonicalSearchService, SearchError, SearchIndex +from gewu.domain.contracts import ProjectRecord, ProjectStatus, Scope + + +def _service() -> CanonicalSearchService: + return CanonicalSearchService( + index=cast(SearchIndex, object()), + project_ids_provider=lambda: ("dome-performance", "interactive-video"), + canonical_head=lambda: "a" * 40, + default_includes_provider=lambda project_id: ( + ("interactive-video", "shared") if project_id == "dome-performance" else () + ), + ) + + +def _principal(*projects: str) -> Principal: + return Principal("reader", "reader-key", frozenset({"viewer"}), frozenset(projects)) + + +def test_single_project_scope_resolves_defaults_and_shared() -> None: + resolved = _service()._resolve_scope( + Scope(projects=("dome-performance",)), + _principal("dome-performance", "interactive-video", "shared"), + ) + + assert resolved == ("dome-performance", "interactive-video") + + +def test_explicit_empty_includes_suppresses_project_defaults() -> None: + resolved = _service()._resolve_scope( + Scope(projects=("dome-performance",), include_projects=(), include_shared=False), + _principal("dome-performance", "interactive-video", "shared"), + ) + + assert resolved == ("dome-performance",) + + +def test_default_includes_must_be_authorized() -> None: + with pytest.raises(SearchError, match="cannot read every requested project") as error: + _service()._resolve_scope( + Scope(projects=("dome-performance",)), _principal("dome-performance", "shared") + ) + + assert error.value.status_code == 403 + + +def test_explicit_include_projects_override_defaults() -> None: + resolved = _service()._resolve_scope( + Scope(projects=("dome-performance",), include_projects=("interactive-video",)), + _principal("dome-performance", "interactive-video", "shared"), + ) + + assert resolved == ("dome-performance", "interactive-video") + + +def test_local_project_registry_exposes_validated_default_includes(tmp_path: Path) -> None: + project = ProjectRecord( + project_id="dome-performance", + title="Dome", + status=ProjectStatus.ACTIVE, + north_star="Build useful dome experiences.", + key_questions=("What works?",), + default_includes=("interactive-video", "shared"), + autonomy_policy_id="dome-policy", + ) + registry_file = tmp_path / "registry.json" + registry_file.write_text( + json.dumps( + {"schema_version": 1, "projects": [project.model_dump(mode="json")], "policies": []} + ), + encoding="utf-8", + ) + + registry = LocalProjectRegistry(registry_file) + + assert registry.default_includes("dome-performance") == ("interactive-video",) diff --git a/tests/application/test_uploads.py b/tests/application/test_uploads.py new file mode 100644 index 0000000..7c37ea1 --- /dev/null +++ b/tests/application/test_uploads.py @@ -0,0 +1,395 @@ +import hashlib +from pathlib import Path + +import pytest + +from gewu.application.uploads import CHUNK_BYTES, LocalArtifactStore, UploadError +from tests.domain.proposal_fixtures import EXTRACTION + + +def _begin(store: LocalArtifactStore, content: bytes, *, now: int = 1_000) -> str: + session = store.begin( + filename="selected.md", + media_type="text/markdown", + byte_length=len(content), + expected_sha256=hashlib.sha256(content).hexdigest(), + now=now, + ) + return session.upload_id + + +def test_matching_chunk_retry_and_restart_are_idempotent(tmp_path: Path) -> None: + content = b"# Evidence\nA source block.\n" + first_store = LocalArtifactStore(tmp_path) + upload_id = _begin(first_store, content) + digest = hashlib.sha256(content).hexdigest() + assert first_store.put_chunk( + upload_id, offset=0, content=content, chunk_sha256=digest, now=1_001 + ) == (0,) + + restarted = LocalArtifactStore(tmp_path) + assert restarted.verified_offsets(upload_id, now=1_002) == (0,) + assert restarted.put_chunk( + upload_id, offset=0, content=content, chunk_sha256=digest, now=1_002 + ) == (0,) + receipt = restarted.finalize(upload_id, now=1_003) + assert receipt.sha256 == digest + assert receipt.status == "staged" + with restarted.open_artifact(receipt.sha256) as artifact: + assert artifact.read() == content + + +def test_pasted_capture_receipt_survives_restart_and_conflicts_on_changed_input( + tmp_path: Path, +) -> None: + first = LocalArtifactStore(tmp_path) + result = {"sha256": "a" * 64, "source_candidate": {"revision_id": "paste:r1"}} + with first.capture_receipt_guard(): + first.save_capture_receipt( + "capture-1", + owner_id="agent-a", + project_id="project-a", + request_sha256="b" * 64, + result=result, + ) + + restarted = LocalArtifactStore(tmp_path) + with restarted.capture_receipt_guard(): + assert ( + restarted.get_capture_receipt( + "capture-1", + owner_id="agent-a", + project_id="project-a", + request_sha256="b" * 64, + ) + == result + ) + with pytest.raises(UploadError) as error: + restarted.get_capture_receipt( + "capture-1", + owner_id="agent-a", + project_id="project-a", + request_sha256="c" * 64, + ) + assert error.value.code == "CAPTURE_CONFLICT" + with pytest.raises(UploadError) as error: + restarted.get_capture_receipt( + "capture-1", + owner_id="agent-a", + project_id="project-b", + request_sha256="b" * 64, + ) + assert error.value.code == "CAPTURE_CONFLICT" + assert ( + restarted.get_capture_receipt( + "capture-1", + owner_id="agent-b", + project_id="project-a", + request_sha256="b" * 64, + ) + is None + ) + + +def test_upload_session_is_private_to_its_principal(tmp_path: Path) -> None: + store = LocalArtifactStore(tmp_path) + content = b"# Scoped upload\n" + upload_id = store.begin( + filename="scoped.md", + media_type="text/markdown", + byte_length=len(content), + expected_sha256=hashlib.sha256(content).hexdigest(), + owner_id="agent-a", + ).upload_id + with pytest.raises(UploadError) as error: + store.verified_offsets(upload_id, owner_id="agent-b") + assert error.value.code == "UPLOAD_NOT_FOUND" + with pytest.raises(UploadError) as error: + store.put_chunk( + upload_id, + offset=0, + content=content, + chunk_sha256=hashlib.sha256(content).hexdigest(), + owner_id="agent-b", + ) + assert error.value.code == "UPLOAD_NOT_FOUND" + + +def test_staged_extraction_is_immutable_and_principal_scoped(tmp_path: Path) -> None: + store = LocalArtifactStore(tmp_path) + store.save_extraction(EXTRACTION, owner_id="agent-a") + assert store.get_extraction(EXTRACTION.extraction_id, owner_id="agent-a") == EXTRACTION + assert store.get_extraction(EXTRACTION.extraction_id, owner_id="agent-b") is None + with pytest.raises(UploadError) as error: + store.save_extraction(EXTRACTION, owner_id="agent-b") + assert error.value.code == "EXTRACTION_STORE_CONFLICT" + + +def test_artifact_access_is_scoped_to_principal_and_project(tmp_path: Path) -> None: + store = LocalArtifactStore(tmp_path) + content = b"private project source" + digest = hashlib.sha256(content).hexdigest() + upload_id = store.begin( + filename="source.md", + media_type="text/markdown", + byte_length=len(content), + expected_sha256=digest, + owner_id="agent-a", + project_id="project-a", + ).upload_id + store.put_chunk( + upload_id, + offset=0, + content=content, + chunk_sha256=digest, + owner_id="agent-a", + ) + store.finalize(upload_id, owner_id="agent-a") + assert ( + store.artifact_media_type(digest, owner_id="agent-a", project_id="project-a") + == "text/markdown" + ) + with store.open_artifact(digest, owner_id="agent-a", project_id="project-a") as stream: + assert stream.read() == content + with pytest.raises(UploadError): + store.open_artifact(digest, owner_id="agent-b", project_id="project-a") + with pytest.raises(UploadError): + store.open_artifact(digest, owner_id="agent-a", project_id="project-b") + + +def test_changed_retry_at_same_offset_is_rejected(tmp_path: Path) -> None: + store = LocalArtifactStore(tmp_path) + upload_id = _begin(store, b"abc") + store.put_chunk( + upload_id, + offset=0, + content=b"abc", + chunk_sha256=hashlib.sha256(b"abc").hexdigest(), + now=1_001, + ) + with pytest.raises(UploadError, match="different bytes") as error: + store.put_chunk( + upload_id, + offset=0, + content=b"xyz", + chunk_sha256=hashlib.sha256(b"xyz").hexdigest(), + now=1_002, + ) + assert error.value.code == "IDEMPOTENCY_CONFLICT" + + +def test_chunk_and_complete_artifact_digests_are_verified(tmp_path: Path) -> None: + store = LocalArtifactStore(tmp_path) + content = b"verified bytes" + digest = hashlib.sha256(content).hexdigest() + upload_id = store.begin( + filename="verified.md", + media_type="text/markdown", + byte_length=len(content), + expected_sha256="f" * 64, + ).upload_id + + with pytest.raises(UploadError) as error: + store.put_chunk( + upload_id, + offset=0, + content=content, + chunk_sha256="0" * 64, + ) + assert error.value.code == "CHUNK_DIGEST_MISMATCH" + + store.put_chunk(upload_id, offset=0, content=content, chunk_sha256=digest) + with pytest.raises(UploadError) as error: + store.finalize(upload_id) + assert error.value.code == "ARTIFACT_DIGEST_MISMATCH" + with pytest.raises(UploadError) as error: + store.open_artifact("f" * 64) + assert error.value.code == "ARTIFACT_NOT_FOUND" + + +def test_interrupted_upload_does_not_publish_an_artifact(tmp_path: Path) -> None: + store = LocalArtifactStore(tmp_path) + content = b"a" * (CHUNK_BYTES + 1) + digest = hashlib.sha256(content).hexdigest() + upload_id = store.begin( + filename="partial.md", + media_type="text/markdown", + byte_length=len(content), + expected_sha256=digest, + owner_id="agent-a", + project_id="project-a", + ).upload_id + first_chunk = content[:CHUNK_BYTES] + store.put_chunk( + upload_id, + offset=0, + content=first_chunk, + chunk_sha256=hashlib.sha256(first_chunk).hexdigest(), + owner_id="agent-a", + ) + + with pytest.raises(UploadError) as error: + store.finalize(upload_id, owner_id="agent-a") + assert error.value.code == "UPLOAD_INCOMPLETE" + with pytest.raises(UploadError) as error: + store.open_artifact(digest, owner_id="agent-a", project_id="project-a") + assert error.value.code == "ARTIFACT_NOT_FOUND" + + +def test_symlinked_upload_paths_are_not_followed(tmp_path: Path) -> None: + store = LocalArtifactStore(tmp_path) + outside = tmp_path / "outside" + outside.mkdir() + sensitive = outside / "chunk" + sensitive.write_bytes(b"data") + + forged_id = "A" * 24 + try: + (store.upload_root / forged_id).symlink_to(outside, target_is_directory=True) + except OSError as error: + pytest.skip(f"directory symlinks are unavailable: {error}") + with pytest.raises(UploadError) as error: + store.verified_offsets(forged_id) + assert error.value.code == "UPLOAD_NOT_FOUND" + + upload_id = _begin(store, b"data") + chunk_link = store.upload_root / upload_id / f"{0:020d}.chunk" + chunk_link.symlink_to(sensitive) + assert store.verified_offsets(upload_id, now=1_001) == () + with pytest.raises(UploadError) as error: + store.finalize(upload_id, now=1_001) + assert error.value.code == "UPLOAD_INCOMPLETE" + assert sensitive.read_bytes() == b"data" + + +def test_upload_requires_order_complete_digest_and_utf8(tmp_path: Path) -> None: + store = LocalArtifactStore(tmp_path) + content = b"a" * (CHUNK_BYTES + 1) + upload_id = _begin(store, content) + final_chunk = content[CHUNK_BYTES:] + with pytest.raises(UploadError, match="in order"): + store.put_chunk( + upload_id, + offset=CHUNK_BYTES, + content=final_chunk, + chunk_sha256=hashlib.sha256(final_chunk).hexdigest(), + now=1_001, + ) + with pytest.raises(UploadError) as error: + store.finalize(upload_id, now=1_002) + assert error.value.code == "UPLOAD_INCOMPLETE" + + invalid_bytes = b"\xff" + invalid_id = _begin(store, invalid_bytes) + store.put_chunk( + invalid_id, + offset=0, + content=invalid_bytes, + chunk_sha256=hashlib.sha256(invalid_bytes).hexdigest(), + now=1_001, + ) + with pytest.raises(UploadError, match="UTF-8") as error: + store.finalize(invalid_id, now=1_002) + assert error.value.code == "TEXT_ENCODING_INVALID" + + +def test_rejects_unsupported_types_expiry_and_malicious_ids(tmp_path: Path) -> None: + store = LocalArtifactStore(tmp_path) + with pytest.raises(UploadError) as error: + store.begin( + filename="payload.exe", + media_type="application/octet-stream", + byte_length=4, + expected_sha256=hashlib.sha256(b"data").hexdigest(), + now=1_000, + ) + assert error.value.code == "UNSUPPORTED_MEDIA_TYPE" + + upload_id = _begin(store, b"data") + with pytest.raises(UploadError) as error: + store.verified_offsets(upload_id, now=1_601) + assert error.value.code == "UPLOAD_EXPIRED" + with pytest.raises(UploadError) as error: + store.verified_offsets("../../outside", now=1_001) + assert error.value.code == "UPLOAD_NOT_FOUND" + + +def test_pdf_upload_requires_matching_suffix_and_pdf_signature(tmp_path: Path) -> None: + store = LocalArtifactStore(tmp_path) + payload = b"%PDF-fixture" + digest = hashlib.sha256(payload).hexdigest() + with pytest.raises(UploadError) as error: + store.begin( + filename="source.txt", + media_type="application/pdf", + byte_length=len(payload), + expected_sha256=digest, + ) + assert error.value.code == "UNSUPPORTED_MEDIA_TYPE" + upload_id = store.begin( + filename="source.pdf", + media_type="application/pdf", + byte_length=len(payload), + expected_sha256=digest, + ).upload_id + store.put_chunk(upload_id, offset=0, content=payload, chunk_sha256=digest) + assert store.finalize(upload_id).media_type == "application/pdf" + + invalid = b"not pdf" + invalid_digest = hashlib.sha256(invalid).hexdigest() + invalid_id = store.begin( + filename="invalid.pdf", + media_type="application/pdf", + byte_length=len(invalid), + expected_sha256=invalid_digest, + ).upload_id + store.put_chunk(invalid_id, offset=0, content=invalid, chunk_sha256=invalid_digest) + with pytest.raises(UploadError) as error: + store.finalize(invalid_id) + assert error.value.code == "PDF_INVALID" + + +def test_identical_artifact_bytes_dedupe_without_collapsing_upload_receipts( + tmp_path: Path, +) -> None: + store = LocalArtifactStore(tmp_path) + content = b"Shared source content." + digest = hashlib.sha256(content).hexdigest() + first_id = store.begin( + filename="shared.md", + media_type="text/markdown", + byte_length=len(content), + expected_sha256=digest, + owner_id="agent-shared", + project_id="project-a", + ).upload_id + second_session = store.begin( + filename="second-copy.txt", + media_type="text/plain", + byte_length=len(content), + expected_sha256=digest, + owner_id="agent-shared", + project_id="project-b", + now=1_000, + ) + for upload_id in (first_id, second_session.upload_id): + store.put_chunk( + upload_id, + offset=0, + content=content, + chunk_sha256=digest, + owner_id="agent-shared", + now=1_001, + ) + first = store.finalize(first_id, owner_id="agent-shared", now=1_002) + second = store.finalize(second_session.upload_id, owner_id="agent-shared", now=1_002) + assert first.sha256 == second.sha256 + assert first.byte_length == second.byte_length == len(content) + for project_id in ("project-a", "project-b"): + with store.open_artifact( + digest, owner_id="agent-shared", project_id=project_id + ) as artifact: + assert artifact.read() == content + with pytest.raises(UploadError) as error: + store.open_artifact(digest, owner_id="agent-shared", project_id="project-c") + assert error.value.code == "ARTIFACT_NOT_FOUND" diff --git a/tests/contracts/__init__.py b/tests/contracts/__init__.py new file mode 100644 index 0000000..8a45ec3 --- /dev/null +++ b/tests/contracts/__init__.py @@ -0,0 +1 @@ +"""Reusable contract checks for replaceable implementation adapters.""" diff --git a/tests/contracts/test_canonical_repository_contract.py b/tests/contracts/test_canonical_repository_contract.py new file mode 100644 index 0000000..defed7e --- /dev/null +++ b/tests/contracts/test_canonical_repository_contract.py @@ -0,0 +1,188 @@ +"""Shared canonical repository contract and a substitutable consumer test.""" + +from __future__ import annotations + +import hashlib +import json +from collections.abc import Mapping +from pathlib import Path + +import pytest +import yaml + +from gewu.adapters.git_repository import GitCanonicalRepository +from gewu.application.identity import Principal +from gewu.application.ports import ( + CanonicalConflictError, + CanonicalRepository, + CanonicalRepositoryError, + validate_canonical_path, +) +from gewu.application.reading import CanonicalNodeReader +from gewu.domain.contracts import KnowledgeNode +from tests.domain.proposal_fixtures import NODE + + +class InMemoryCanonicalRepository: + """Independent port implementation used to prove consumer substitution.""" + + def __init__(self) -> None: + initial = hashlib.sha256(b"memory-repository-initial").hexdigest()[:40] + self._head = initial + self._snapshots = {initial: {}} + self._parents: dict[str, str | None] = {initial: None} + self._changed: dict[str, frozenset[str]] = {initial: frozenset()} + + def head(self) -> str: + return self._head + + def read_file(self, revision: str, path: str) -> bytes | None: + validate_canonical_path(path) + try: + return self._snapshots[revision].get(path) + except KeyError as error: + raise CanonicalRepositoryError("Unknown canonical revision") from error + + def list_files(self, revision: str, prefix: str) -> tuple[str, ...]: + prefix = prefix.rstrip("/") + validate_canonical_path(prefix) + try: + files = self._snapshots[revision] + except KeyError as error: + raise CanonicalRepositoryError("Unknown canonical revision") from error + return tuple(sorted(path for path in files if path.startswith(prefix))) + + def publish_files( + self, + *, + expected_head: str, + files: Mapping[str, bytes], + message: str, + index_directory: Path, + ) -> str: + del message, index_directory + if expected_head != self._head: + raise CanonicalConflictError("Canonical branch changed before publication") + if not files: + raise CanonicalRepositoryError("Canonical publication requires at least one file") + for path in files: + validate_canonical_path(path) + parent_files = self._snapshots[self._head] + changed = frozenset(path for path, data in files.items() if parent_files.get(path) != data) + digest = hashlib.sha256() + digest.update(self._head.encode()) + for path, data in sorted(files.items()): + digest.update(path.encode()) + digest.update(data) + revision = digest.hexdigest()[:40] + snapshot = {**parent_files, **files} + self._snapshots[revision] = snapshot + self._parents[revision] = self._head + self._changed[revision] = changed + self._head = revision + return revision + + def introducing_commit(self, revision: str, path: str) -> str | None: + revisions = self.history(revision, path, 50) + return revisions[-1] if revisions else None + + def history(self, revision: str, path: str, limit: int) -> tuple[str, ...]: + validate_canonical_path(path) + if not 1 <= limit <= 50: + raise CanonicalRepositoryError("Canonical history limit is invalid") + if revision not in self._snapshots: + raise CanonicalRepositoryError("Unknown canonical revision") + revisions: list[str] = [] + cursor: str | None = revision + while cursor is not None and len(revisions) < limit: + if path in self._changed[cursor]: + revisions.append(cursor) + cursor = self._parents[cursor] + return tuple(revisions) + + +@pytest.fixture(params=("git", "memory"), ids=("git-adapter", "in-memory-substitute")) +def canonical_repository(request: pytest.FixtureRequest, tmp_path: Path) -> CanonicalRepository: + if request.param == "git": + return GitCanonicalRepository(tmp_path / "knowledge.git") + return InMemoryCanonicalRepository() + + +def test_canonical_repository_contract( + canonical_repository: CanonicalRepository, + tmp_path: Path, +) -> None: + base = canonical_repository.head() + record = "projects/project-a/nodes/node-a.yaml" + source = "sources/source-a/revision-a.yaml" + first = canonical_repository.publish_files( + expected_head=base, + files={record: b"title: first\n", source: b"title: source\n"}, + message="Initial records", + index_directory=tmp_path / "publishing", + ) + assert canonical_repository.head() == first + assert canonical_repository.read_file(first, record) == b"title: first\n" + assert canonical_repository.list_files(first, "projects/") == (record,) + assert canonical_repository.read_file(first, "unrelated/user-file.txt") is None + assert canonical_repository.introducing_commit(first, record) == first + assert canonical_repository.history(first, record, 20) == (first,) + assert canonical_repository.introducing_commit(first, "projects/absent.yaml") is None + + second = canonical_repository.publish_files( + expected_head=first, + files={record: b"title: second\n"}, + message="Update record", + index_directory=tmp_path / "publishing", + ) + assert canonical_repository.read_file(first, record) == b"title: first\n" + assert canonical_repository.read_file(second, record) == b"title: second\n" + assert canonical_repository.history(second, record, 20) == (second, first) + assert canonical_repository.introducing_commit(second, record) == first + with pytest.raises(CanonicalConflictError): + canonical_repository.publish_files( + expected_head=base, + files={record: b"stale\n"}, + message="Stale update", + index_directory=tmp_path / "publishing", + ) + assert canonical_repository.head() == second + with pytest.raises(CanonicalRepositoryError): + canonical_repository.read_file(second, "../outside.yaml") + with pytest.raises(CanonicalRepositoryError): + canonical_repository.publish_files( + expected_head=second, + files={"../outside.yaml": b"unsafe\n"}, + message="Unsafe update", + index_directory=tmp_path / "publishing", + ) + + +def test_node_reader_works_unchanged_with_substitute_repository(tmp_path: Path) -> None: + repository = InMemoryCanonicalRepository() + project_hash = hashlib.sha256(NODE.project_id.encode()).hexdigest() + node_hash = hashlib.sha256(NODE.node_id.encode()).hexdigest() + path = f"projects/{project_hash}/nodes/{node_hash}.yaml" + raw = yaml.safe_dump(NODE.model_dump(mode="json"), allow_unicode=True).encode() + revision = repository.publish_files( + expected_head=repository.head(), + files={path: raw}, + message="Publish reader fixture", + index_directory=tmp_path / "publishing", + ) + + reader = CanonicalNodeReader(repository) + response = reader.get_node( + NODE.project_id, + NODE.node_id, + principal=Principal( + "reader", + "reader-key", + frozenset({"viewer"}), + frozenset({NODE.project_id}), + ), + ) + loaded = KnowledgeNode.model_validate_json(json.dumps(response.data[0]), strict=True) + assert loaded == NODE + assert response.canonical_revision == revision + assert response.citations == NODE.claims[0].evidence diff --git a/tests/domain/__init__.py b/tests/domain/__init__.py new file mode 100644 index 0000000..eb6471c --- /dev/null +++ b/tests/domain/__init__.py @@ -0,0 +1 @@ +"""Domain contract tests and fixtures.""" diff --git a/tests/domain/proposal_fixtures.py b/tests/domain/proposal_fixtures.py new file mode 100644 index 0000000..f5aec85 --- /dev/null +++ b/tests/domain/proposal_fixtures.py @@ -0,0 +1,212 @@ +"""Golden pilot proposal fixtures for deterministic evidence and policy checks.""" + +from datetime import UTC, datetime + +from gewu.domain.contracts import ( + AutoAcceptPolicy, + Claim, + Confidence, + EvidenceReference, + ExtractionBlock, + ExtractionRecord, + KnowledgeNode, + LensFit, + Lifecycle, + Locator, + NodeType, + ProjectRecord, + ProjectStatus, + ProposalOperation, + ProposalRevision, + ReliabilityGrade, + ReviewState, + Scope, + SourceRevisionRecord, + SupportAssessment, + SupportStatus, +) +from gewu.domain.validation import ( + assertion_digest, + evidence_digest, + extraction_digest, + proposal_payload_digest, +) + +BASE_COMMIT = "1" * 40 +NOW = datetime(2026, 10, 13, tzinfo=UTC) +SOURCE_REVISION = SourceRevisionRecord( + source_id="shared:pilot-report", + revision_id="shared:pilot-report:r1", + artifact_sha256="2" * 64, + media_type="text/markdown", + byte_length=100, + captured_at=NOW, + original_url=None, + title="Pilot dome installation report", + authors=("Pilot team",), + publisher="GEWU fixtures", + license="internal", + source_type="internal", + reliability_grade=ReliabilityGrade.MEDIUM, + ingested_for=("dome-performance",), + archive_status="archived", +) +BLOCK_TEXT = "The installation hosted 400 visitors during the 2026 season." +_EXTRACTION_UNHASHED = ExtractionRecord( + extraction_id="0" * 64, + source_revision_id=SOURCE_REVISION.revision_id, + parser_name="gewu-text", + parser_version="0.1.0", + parser_config_sha256="4" * 64, + normalization_profile="unicode-nfc-lf-v1", + status="complete", + blocks=( + ExtractionBlock( + block_id="block-1", + ordinal=0, + original_text=BLOCK_TEXT, + normalized_text=BLOCK_TEXT, + normalization_map=tuple((index, index + 1) for index in range(len(BLOCK_TEXT))), + page=None, + ), + ), +) +EXTRACTION = _EXTRACTION_UNHASHED.model_copy( + update={"extraction_id": extraction_digest(_EXTRACTION_UNHASHED)} +) +CLAIM_TEXT = "The installation hosted 400 visitors during the 2026 season." +CLAIM_DIGEST = assertion_digest(CLAIM_TEXT) +START = 0 +END = len(BLOCK_TEXT) +EVIDENCE = EvidenceReference( + source_revision_id=SOURCE_REVISION.revision_id, + extraction_id=EXTRACTION.extraction_id, + assertion_path="claims[0].text", + assertion_digest=CLAIM_DIGEST, + quote=BLOCK_TEXT, + locator=Locator(kind="text_span", block_id="block-1", start=START, end=END), + normalization_profile="unicode-nfc-lf-v1", +) +CLAIM = Claim( + claim_id="dome:claim-visitors", + text=CLAIM_TEXT, + evidence_type="reported", + evidence=(EVIDENCE,), + assessment=SupportAssessment( + assessment_id="assessment-1", + assertion_digest=CLAIM_DIGEST, + evidence_digests=(evidence_digest(EVIDENCE),), + assessor_id="agent-pilot", + run_id="run-pilot-1", + method="paired source claim check", + status=SupportStatus.SUPPORTED, + rationale="The quoted sentence states the count and period.", + ), +) +NODE = KnowledgeNode( + node_id="dome:season-visitors", + project_id="dome-performance", + node_type=NodeType.DATAPOINT, + title="Dome season visitors", + summary="The installation hosted 400 visitors in the 2026 season.", + created=NOW, + updated=NOW, + review_state=ReviewState.DRAFT, + lifecycle=Lifecycle.ACTIVE, + confidence=Confidence.MEDIUM, + claim_support="supported", + tags=("attendance",), + audience=("product",), + domain=("production",), + use_for=("comparison",), + lens_fit={ + "dome-performance": LensFit( + score=0.9, + horizon="H1", + key_question="How do audiences engage?", + reason="Reports a representative audience measure.", + ), + }, + run_id="run-pilot-1", + agent_id="agent-pilot", + model_id="fixture-model", + claims=(CLAIM,), + as_of=NOW, + stale_after=datetime(2027, 1, 1, tzinfo=UTC), + value=400, + unit="visitors", + sample_size=None, + reporting_year=2026, +) +PROJECTS = { + "dome-performance": ProjectRecord( + project_id="dome-performance", + title="Dome interactive content", + status=ProjectStatus.ACTIVE, + north_star="Create compelling interactive experiences for dome audiences.", + key_questions=("How do audiences engage?",), + autonomy_policy_id="dome-policy", + ), + "interactive-video": ProjectRecord( + project_id="interactive-video", + title="Interactive video", + status=ProjectStatus.ACTIVE, + north_star="Understand useful interactive video experiences.", + key_questions=("How do teams control branching?",), + autonomy_policy_id="video-policy", + ), +} +POLICIES = { + "dome-policy": AutoAcceptPolicy( + policy_id="dome-policy", + policy_version=1, + project_id="dome-performance", + automatic_node_types=("datapoint", "problem", "technique", "case"), + automatic_source_registration=True, + require_supported_assessment=True, + ), + "video-policy": AutoAcceptPolicy( + policy_id="video-policy", + policy_version=1, + project_id="interactive-video", + automatic_node_types=("datapoint", "problem", "technique", "case"), + automatic_source_registration=True, + require_supported_assessment=True, + ), + "shared": AutoAcceptPolicy( + policy_id="shared", + policy_version=1, + project_id="shared", + automatic_source_registration=True, + require_supported_assessment=True, + ), +} + + +def proposal( + *, + node: KnowledgeNode | None = NODE, + source: SourceRevisionRecord | None = SOURCE_REVISION, + scope: Scope | None = None, + base_commit: str = BASE_COMMIT, +) -> ProposalRevision: + operations = tuple( + operation + for operation in ( + ProposalOperation(operation="register_source", source=source) if source else None, + ProposalOperation(operation="upsert_node", node=node) if node else None, + ) + if operation is not None + ) + candidate = ProposalRevision( + proposal_id="proposal-pilot-1", + revision=1, + initiating_project_id="dome-performance", + scope=scope or Scope(projects=("dome-performance",), include_shared=True), + base_commit=base_commit, + payload_digest="0" * 64, + proposer_id="agent-pilot", + run_id="run-pilot-1", + operations=operations, + ) + return candidate.model_copy(update={"payload_digest": proposal_payload_digest(candidate)}) diff --git a/tests/domain/test_agent_pack_manifest.py b/tests/domain/test_agent_pack_manifest.py new file mode 100644 index 0000000..c37f5d7 --- /dev/null +++ b/tests/domain/test_agent_pack_manifest.py @@ -0,0 +1,14 @@ +import subprocess +import sys + +from scripts.export_agent_contract import PACK + + +def test_pack_manifest_and_nested_contract_hashes_verify() -> None: + result = subprocess.run( + [sys.executable, str(PACK / "scripts" / "manifest_tool.py"), "verify"], + capture_output=True, + check=False, + text=True, + ) + assert result.returncode == 0, result.stderr diff --git a/tests/domain/test_catalog.py b/tests/domain/test_catalog.py new file mode 100644 index 0000000..7fa3cdb --- /dev/null +++ b/tests/domain/test_catalog.py @@ -0,0 +1,40 @@ +import json +from pathlib import Path + +from gewu.domain.operations import OPERATIONS, Transport, catalog_bytes, operations_for + + +def test_read_http_catalog_contains_no_mutation_operations() -> None: + http_operations = operations_for(Transport.HTTP) + assert all(not operation.mutates_knowledge for operation in http_operations) + assert not {"proposal_submit", "owner_decide"} & {item.name for item in http_operations} + + +def test_owner_decision_is_separate_from_agent_mcp() -> None: + agent_operations = {item.name for item in operations_for(Transport.MCP)} + owner_operations = {item.name for item in operations_for(Transport.OWNER_MCP)} + assert "owner_decide" not in agent_operations + assert owner_operations == {"owner_challenge_get", "owner_diff_get", "owner_decide"} + + +def test_stdio_connector_has_only_selected_file_operations() -> None: + stdio_operations = {item.name for item in operations_for(Transport.STDIO)} + assert stdio_operations == {"list_selected_files", "upload_selected_file"} + + +def test_exported_catalog_matches_the_application_allowlist() -> None: + path = Path(__file__).resolve().parents[2] / "schema" / "v1" / "catalog.json" + catalog = json.loads(path.read_text(encoding="utf-8")) + assert path.read_bytes() == catalog_bytes() + names_by_transport = { + transport.value: {operation.name for operation in operations_for(transport)} + for transport in Transport + } + entries = {operation["name"]: operation for operation in catalog["operations"]} + assert set(entries) == {operation.name for operation in OPERATIONS} + for name, entry in entries.items(): + assert set(entry["transports"]) == { + transport for transport, names in names_by_transport.items() if name in names + } + expected_mutates = next(item.mutates_knowledge for item in OPERATIONS if item.name == name) + assert entry["mutates_knowledge"] is expected_mutates diff --git a/tests/domain/test_contracts.py b/tests/domain/test_contracts.py new file mode 100644 index 0000000..eb99527 --- /dev/null +++ b/tests/domain/test_contracts.py @@ -0,0 +1,231 @@ +import json +from datetime import UTC, date, datetime +from pathlib import Path + +import pytest +from pydantic import ValidationError + +from gewu.domain.contracts import ( + AutoAcceptPolicy, + Confidence, + Locator, + NodeType, + ProjectRecord, + ProjectStatus, + ProposalReceipt, + ProposalState, + ReliabilityGrade, + Scope, + SearchRequest, + SourceRevisionRecord, +) + + +def test_project_contract_rejects_unknown_fields() -> None: + with pytest.raises(ValidationError): + ProjectRecord.model_validate( + { + "project_id": "dome", + "title": "Dome", + "status": ProjectStatus.ACTIVE, + "north_star": "Understand interactive environments", + "key_questions": ("What works?",), + "autonomy_policy_id": "pilot", + "untrusted_extra": "ignored?", + } + ) + + +def test_project_status_supports_pause_without_losing_registry_record() -> None: + project = ProjectRecord( + project_id="dome", + title="Dome", + status=ProjectStatus.PAUSED, + north_star="Understand interactive environments", + key_questions=("What works?",), + autonomy_policy_id="pilot", + ) + assert ( + ProjectRecord.model_validate_json(project.model_dump_json()).status == ProjectStatus.PAUSED + ) + + +@pytest.mark.parametrize("project_id", ["Dome", "two_words", "project.slug"]) +def test_project_ids_must_be_lowercase_slugs(project_id: str) -> None: + with pytest.raises(ValidationError): + ProjectRecord( + project_id=project_id, + title="Invalid project ID", + status=ProjectStatus.ACTIVE, + north_star="A stable project lens.", + key_questions=("What matters?",), + autonomy_policy_id="policy", + ) + + +def test_scope_requires_explicit_all_projects_flag() -> None: + with pytest.raises(ValidationError): + Scope(projects=("all",)) + assert Scope(projects=("all",), all_projects=True) + + +@pytest.mark.parametrize("unsafe_id", ["../private", "bad/id", " leading-space"]) +def test_scope_rejects_unsafe_project_ids(unsafe_id: str) -> None: + with pytest.raises(ValidationError): + Scope(projects=(unsafe_id,)) + + +def test_search_facets_are_typed_and_reject_duplicate_node_types() -> None: + request = SearchRequest( + query="audience", + scope=Scope(projects=("dome-performance",)), + node_types=(NodeType.PROBLEM, NodeType.TECHNIQUE), + tags=("attendance", "real-time"), + audience=("product",), + use_for=("comparison",), + domain=("production",), + confidences=(Confidence.MEDIUM,), + as_of_from=date(2024, 1, 1), + as_of_to=date(2024, 12, 31), + stale_only=True, + budget_tokens=1000, + ) + assert request.stale_only + assert request.tags == ("attendance", "real-time") + assert request.domain == ("production",) + assert request.confidences == (Confidence.MEDIUM,) + assert request.as_of_from == date(2024, 1, 1) + with pytest.raises(ValidationError, match="must not be later"): + SearchRequest( + query="audience", + scope=Scope(projects=("dome-performance",)), + as_of_from=date(2025, 1, 1), + as_of_to=date(2024, 12, 31), + budget_tokens=1000, + ) + with pytest.raises(ValidationError, match="confidences must be unique"): + SearchRequest( + query="audience", + scope=Scope(projects=("dome-performance",)), + confidences=(Confidence.MEDIUM, Confidence.MEDIUM), + budget_tokens=1000, + ) + with pytest.raises(ValidationError): + SearchRequest( + query="audience", + scope=Scope(projects=("dome-performance",)), + node_types=(NodeType.PROBLEM, NodeType.PROBLEM), + budget_tokens=1000, + ) + with pytest.raises(ValidationError): + SearchRequest( + query="audience", + scope=Scope(projects=("dome-performance",)), + tags=("attendance", "attendance"), + budget_tokens=1000, + ) + with pytest.raises(ValidationError): + SearchRequest( + query="audience", + scope=Scope(projects=("dome-performance",)), + domain=(" production",), + budget_tokens=1000, + ) + + +def test_search_allows_empty_query_only_for_a_typed_browse() -> None: + request = SearchRequest( + scope=Scope(projects=("dome-performance",)), + node_types=(NodeType.PROBLEM,), + budget_tokens=1000, + ) + assert request.query == "" + with pytest.raises(ValidationError, match="search requires text"): + SearchRequest( + scope=Scope(projects=("dome-performance",)), + budget_tokens=1000, + ) + with pytest.raises(ValidationError, match="search requires text"): + SearchRequest( + query=" ", + scope=Scope(projects=("dome-performance",)), + budget_tokens=1000, + ) + + +def test_pdf_locator_uses_one_based_page_and_nonempty_span() -> None: + with pytest.raises(ValidationError): + Locator(kind="pdf_page_span", block_id="p1-b1", start=8, end=8, page=0) + + +def test_unarchived_source_requires_a_url_and_cannot_claim_artifact_bytes() -> None: + base = { + "source_id": "project:bookmark", + "revision_id": "project:bookmark:r1", + "media_type": "application/octet-stream", + "byte_length": 0, + "captured_at": datetime.now(UTC), + "original_url": "https://example.org/paper", + "title": "Referenced paper", + "license": "unspecified", + "source_type": "paper", + "reliability_grade": ReliabilityGrade.MEDIUM, + "ingested_for": ("dome-performance",), + "archive_status": "reference-only", + } + record = SourceRevisionRecord.model_validate(base) + assert record.artifact_sha256 is None + with pytest.raises(ValidationError): + SourceRevisionRecord.model_validate({**base, "byte_length": 10}) + with pytest.raises(ValidationError): + SourceRevisionRecord.model_validate({**base, "original_url": None}) + + +def test_receipt_is_immutable_and_versioned() -> None: + receipt = ProposalReceipt( + proposal_id="proposal-1", + revision=1, + payload_digest="a" * 64, + state=ProposalState.ACCEPTED, + outcome="accepted", + commit_id="b" * 64, + decided_at=datetime.now(UTC), + ) + assert receipt.schema_version == 1 + with pytest.raises(ValidationError): + receipt.revision = 2 # type: ignore[misc] + + +def test_pilot_registry_fixture_matches_project_and_policy_contracts() -> None: + fixture_path = ( + Path(__file__).resolve().parents[2] / "schema" / "fixtures" / "pilot-projects.json" + ) + + def reject_duplicate_keys(pairs: list[tuple[str, object]]) -> dict[str, object]: + result: dict[str, object] = {} + for key, value in pairs: + if key in result: + raise ValueError(f"duplicate JSON key: {key}") + result[key] = value + return result + + fixture = json.loads( + fixture_path.read_text(encoding="utf-8"), object_pairs_hook=reject_duplicate_keys + ) + projects = tuple( + ProjectRecord.model_validate_json(json.dumps(record), strict=True) + for record in fixture["projects"] + ) + policies = tuple( + AutoAcceptPolicy.model_validate_json(json.dumps(record), strict=True) + for record in fixture["test_policies"] + ) + assert {project.project_id for project in projects} == { + "dome-performance", + "interactive-video", + } + assert {policy.project_id for policy in policies} == { + "dome-performance", + "interactive-video", + "shared", + } diff --git a/tests/domain/test_run_checkpoint.py b/tests/domain/test_run_checkpoint.py new file mode 100644 index 0000000..f802677 --- /dev/null +++ b/tests/domain/test_run_checkpoint.py @@ -0,0 +1,160 @@ +import copy +import hashlib +import json +import stat +from pathlib import Path + +import agents.gewu.scripts.run_checkpoint as checkpoint_module +import pytest +from agents.gewu.scripts.run_checkpoint import ( + create_checkpoint, + load_checkpoint, + update_checkpoint, +) + + +def test_run_checkpoint_records_scope_coverage_receipt_and_pins(tmp_path: Path) -> None: + workspace = tmp_path / "research-workspace" + checkpoint = create_checkpoint( + workspace, + run_id="run-2026-01", + objective="Compare audience response", + projects=["dome-performance"], + include_shared=True, + lens="Audience behavior", + budgets={"sources": 8}, + ) + path = workspace / "Runs" / "run-2026-01.json" + assert stat.S_IMODE(path.stat().st_mode) == 0o600 + assert checkpoint["pack"]["snapshot_digest"] + + updated = update_checkpoint( + workspace, + "run-2026-01", + { + "stage": "report", + "sources": [ + { + "source_revision_id": "source:r1", + "extraction_id": "a" * 64, + "status": "extracted", + } + ], + "coverage": { + "selected": ["section-1", "section-2"], + "included": ["section-1"], + "excluded": [{"item": "section-2", "reason": "outside project lens"}], + "skipped": [], + }, + "pending_operations": [ + { + "operation_id": "proposal-op-1", + "tool_name": "proposal_submit", + "input_sha256": "b" * 64, + "status": "complete", + } + ], + "proposals": [ + { + "proposal_id": "proposal-1", + "revision": 1, + "status": "accepted", + "receipt": { + "proposal_id": "proposal-1", + "revision": 1, + "state": "accepted", + "commit_id": "c" * 40, + }, + } + ], + "outputs": [{"sha256": "d" * 64}], + }, + ) + resumed = load_checkpoint(workspace, "run-2026-01") + assert resumed == updated + assert resumed["coverage"]["excluded"][0]["reason"] == "outside project lens" + assert resumed["proposals"][0]["receipt"]["commit_id"] == "c" * 40 + with pytest.raises(ValueError, match="does not match"): + update_checkpoint( + workspace, + "run-2026-01", + { + "proposals": [ + { + "proposal_id": "proposal-1", + "revision": 1, + "status": "accepted", + "receipt": { + "proposal_id": "proposal-other", + "revision": 1, + "state": "accepted", + "commit_id": "c" * 40, + }, + } + ] + }, + ) + + +def test_run_checkpoint_rejects_secrets_bad_progress_and_snapshot_drift(tmp_path: Path) -> None: + workspace = tmp_path / "research-workspace" + create_checkpoint( + workspace, + run_id="run-drift-01", + objective="Test resume pinning", + projects=["dome-performance"], + include_shared=False, + lens="A bounded lens", + ) + with pytest.raises(ValueError, match="credentials or source bodies"): + update_checkpoint(workspace, "run-drift-01", {"last_failure": {"api_key": "secret"}}) + with pytest.raises(ValueError, match="SHA-256"): + update_checkpoint( + workspace, + "run-drift-01", + { + "outputs": [{"sha256": "not-a-digest"}], + "stage": "report", + }, + ) + + path = workspace / "Runs" / "run-drift-01.json" + checkpoint = json.loads(path.read_text(encoding="utf-8")) + checkpoint["pack"]["snapshot_digest"] = "0" * 64 + path.write_text(json.dumps(checkpoint), encoding="utf-8") + with pytest.raises(ValueError, match="pack changed"): + load_checkpoint(workspace, "run-drift-01") + + +def test_existing_run_cannot_resume_after_verified_pack_upgrade( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + workspace = tmp_path / "research-workspace" + create_checkpoint( + workspace, + run_id="run-pack-update-01", + objective="Resume with pinned modules", + projects=["dome-performance"], + include_shared=True, + lens="Keep the original extraction and evidence rules", + ) + checkpoint_path = workspace / "Runs" / "run-pack-update-01.json" + original_checkpoint = checkpoint_path.read_bytes() + + updated_manifest = copy.deepcopy(checkpoint_module.load_manifest()) + updated_manifest["pack_version"] = "0.7.0" + updated_manifest["modules"][0]["implementation_version"] += ".updated" + modules_json = json.dumps( + updated_manifest["modules"], ensure_ascii=False, sort_keys=True, separators=(",", ":") + ) + updated_manifest["snapshot"]["digest"] = hashlib.sha256(modules_json.encode()).hexdigest() + + # This fixture stands in for a newer pack whose own manifest verification + # succeeded; the behavior under test is the active run's compatibility pin. + monkeypatch.setattr(checkpoint_module, "load_manifest", lambda: updated_manifest) + monkeypatch.setattr(checkpoint_module, "verify", lambda _manifest: None) + + with pytest.raises(ValueError, match="pack changed since this run"): + load_checkpoint(workspace, "run-pack-update-01") + assert checkpoint_path.read_bytes() == original_checkpoint diff --git a/tests/domain/test_validation.py b/tests/domain/test_validation.py new file mode 100644 index 0000000..3318b57 --- /dev/null +++ b/tests/domain/test_validation.py @@ -0,0 +1,698 @@ +from gewu.adapters.text_extractor import TextMarkdownExtractor +from gewu.domain.contracts import ( + EdgeEndpoint, + KnowledgeNode, + Locator, + NodeType, + ProjectStatus, + ProposalOperation, + ProposalRevision, + Scope, + SupportStatus, + TypedEdge, +) +from gewu.domain.validation import ( + ValidationReport, + assertion_digest, + edge_assertion_digest, + evidence_digest, + proposal_payload_digest, + validate_proposal, +) +from tests.domain.proposal_fixtures import ( + BASE_COMMIT, + CLAIM, + EVIDENCE, + EXTRACTION, + NODE, + POLICIES, + PROJECTS, + SOURCE_REVISION, + proposal, +) + + +def _validate(candidate: ProposalRevision) -> ValidationReport: + return validate_proposal( + candidate, + projects=PROJECTS, + policies=POLICIES, + sources={SOURCE_REVISION.revision_id: SOURCE_REVISION}, + extractions={EXTRACTION.extraction_id: EXTRACTION}, + existing_nodes={}, + current_base_commit=BASE_COMMIT, + ) + + +def test_supported_exact_citation_and_both_bundle_policies_are_eligible() -> None: + report = _validate(proposal()) + assert report.status == "eligible", report.findings + assert report.eligible_for_auto_accept + assert set(report.affected_policy_ids) == {"dome-policy", "shared"} + + +def test_transcript_timestamp_locator_must_match_the_cited_cue() -> None: + transcript_bytes = ( + b"WEBVTT\n\n" + b"cue-1\n00:14:32.000 --> 00:14:36.500\n" + b"The installation hosted 400 visitors during the 2026 season.\n" + ) + source = SOURCE_REVISION.model_copy( + update={ + "media_type": "text/vtt", + "byte_length": len(transcript_bytes), + "artifact_sha256": __import__("hashlib").sha256(transcript_bytes).hexdigest(), + } + ) + extraction = TextMarkdownExtractor().extract(source.revision_id, transcript_bytes, "text/vtt") + cue = next(block for block in extraction.blocks if block.time_start_ms is not None) + quote = CLAIM.text + start = cue.normalized_text.index(quote) + evidence = EVIDENCE.model_copy( + update={ + "extraction_id": extraction.extraction_id, + "quote": quote, + "locator": Locator( + kind="transcript_timestamp_span", + block_id=cue.block_id, + start=start, + end=start + len(quote), + time_start_ms=cue.time_start_ms, + time_end_ms=cue.time_end_ms, + ), + } + ) + assessment = CLAIM.assessment.model_copy( + update={"evidence_digests": (evidence_digest(evidence),)} + ) + claim = CLAIM.model_copy(update={"evidence": (evidence,), "assessment": assessment}) + node = NODE.model_copy(update={"claims": (claim,)}) + eligible = validate_proposal( + proposal(node=node, source=source), + projects=PROJECTS, + policies=POLICIES, + sources={}, + extractions={extraction.extraction_id: extraction}, + existing_nodes={}, + current_base_commit=BASE_COMMIT, + ) + assert eligible.status == "eligible", eligible.findings + + wrong_time = evidence.model_copy( + update={ + "locator": evidence.locator.model_copy( + update={"time_start_ms": evidence.locator.time_start_ms + 1} + ) + } + ) + wrong_assessment = assessment.model_copy( + update={"evidence_digests": (evidence_digest(wrong_time),)} + ) + wrong_claim = claim.model_copy( + update={"evidence": (wrong_time,), "assessment": wrong_assessment} + ) + rejected = validate_proposal( + proposal(node=NODE.model_copy(update={"claims": (wrong_claim,)}), source=None), + projects=PROJECTS, + policies=POLICIES, + sources={source.revision_id: source}, + extractions={extraction.extraction_id: extraction}, + existing_nodes={}, + current_base_commit=BASE_COMMIT, + ) + assert rejected.status == "invalid" + assert "EVIDENCE_LOCATION_MISMATCH" in {finding.code for finding in rejected.findings} + + +def test_paused_or_archived_projects_reject_new_acceptance() -> None: + for status in (ProjectStatus.PAUSED, ProjectStatus.ARCHIVED): + project = PROJECTS["dome-performance"].model_copy(update={"status": status}) + report = validate_proposal( + proposal(), + projects={**PROJECTS, "dome-performance": project}, + policies=POLICIES, + sources={SOURCE_REVISION.revision_id: SOURCE_REVISION}, + extractions={EXTRACTION.extraction_id: EXTRACTION}, + existing_nodes={}, + current_base_commit=BASE_COMMIT, + ) + assert not report.eligible_for_auto_accept + assert any(item.code == "PROJECT_NOT_ACTIVE" for item in report.findings) + + +def test_cross_project_source_bundle_requires_every_affected_policy() -> None: + shared_source = SOURCE_REVISION.model_copy( + update={"ingested_for": ("dome-performance", "interactive-video")} + ) + candidate = proposal( + source=shared_source, + scope=Scope(projects=("dome-performance", "interactive-video"), include_shared=True), + ) + eligible = validate_proposal( + candidate, + projects=PROJECTS, + policies=POLICIES, + sources={}, + extractions={EXTRACTION.extraction_id: EXTRACTION}, + existing_nodes={}, + current_base_commit=BASE_COMMIT, + ) + assert eligible.status == "eligible", eligible.findings + assert set(eligible.affected_policy_ids) == { + "dome-policy", + "video-policy", + "shared", + } + + restrictive_video_policy = POLICIES["video-policy"].model_copy( + update={"automatic_source_registration": False} + ) + blocked = validate_proposal( + candidate, + projects=PROJECTS, + policies={**POLICIES, "video-policy": restrictive_video_policy}, + sources={}, + extractions={EXTRACTION.extraction_id: EXTRACTION}, + existing_nodes={}, + current_base_commit=BASE_COMMIT, + ) + assert blocked.status == "needs-review" + assert not blocked.eligible_for_auto_accept + assert "AUTO_ACCEPT_UNSPECIFIED" in {finding.code for finding in blocked.findings} + + +def test_matching_quote_at_wrong_location_is_rejected() -> None: + evidence = EVIDENCE.model_copy( + update={ + "quote": "400 visitors", + "locator": EVIDENCE.locator.model_copy(update={"start": 0, "end": 13}), + } + ) + changed_claim = CLAIM.model_copy(update={"evidence": (evidence,)}) + changed_node = NODE.model_copy(update={"claims": (changed_claim,)}) + report = _validate(proposal(node=changed_node, source=None)) + assert report.status == "invalid" + assert "EVIDENCE_MISMATCH" in {finding.code for finding in report.findings} + + +def test_reference_only_source_cannot_support_accepted_evidence() -> None: + reference = SOURCE_REVISION.model_copy( + update={ + "artifact_sha256": None, + "media_type": "application/octet-stream", + "byte_length": 0, + "original_url": "https://research.example/missing", + "archive_status": "reference-only", + } + ) + report = validate_proposal( + proposal(source=None), + projects=PROJECTS, + policies=POLICIES, + sources={reference.revision_id: reference}, + extractions={EXTRACTION.extraction_id: EXTRACTION}, + existing_nodes={}, + current_base_commit=BASE_COMMIT, + ) + assert report.status == "invalid" + assert "SOURCE_NOT_ARCHIVED" in {finding.code for finding in report.findings} + + +def test_correct_quote_with_unsupported_assessment_is_rejected() -> None: + assessment = CLAIM.assessment.model_copy(update={"status": SupportStatus.UNSUPPORTED}) + changed_claim = CLAIM.model_copy(update={"assessment": assessment}) + changed_node = NODE.model_copy(update={"claims": (changed_claim,)}) + report = _validate(proposal(node=changed_node, source=None)) + assert report.status == "invalid" + assert "SUPPORT_UNAVAILABLE" in {finding.code for finding in report.findings} + + +def test_node_and_assessor_attribution_cannot_impersonate_another_agent() -> None: + other_assessment = CLAIM.assessment.model_copy(update={"assessor_id": "agent-other"}) + assessment_claim = CLAIM.model_copy(update={"assessment": other_assessment}) + assessment_node = NODE.model_copy(update={"claims": (assessment_claim,)}) + assessment_report = _validate(proposal(node=assessment_node, source=None)) + assert "ASSESSOR_ATTRIBUTION_MISMATCH" in { + finding.code for finding in assessment_report.findings + } + + other_node = NODE.model_copy(update={"agent_id": "agent-other"}) + node_report = _validate(proposal(node=other_node, source=None)) + assert "AGENT_ATTRIBUTION_MISMATCH" in {finding.code for finding in node_report.findings} + + +def test_entity_name_collision_requires_review_without_creating_a_link() -> None: + entity = NODE.model_copy( + update={"node_type": NodeType.ENTITY, "title": "The Dome", "aliases": ("Planetarium",)} + ) + existing = NODE.model_copy( + update={ + "node_type": NodeType.ENTITY, + "node_id": "dome:existing-entity", + "title": " planetarium ", + } + ) + report = validate_proposal( + proposal( + node=entity, + source=None, + scope=Scope(projects=("dome-performance",), include_shared=False), + ), + projects=PROJECTS, + policies=POLICIES, + sources={SOURCE_REVISION.revision_id: SOURCE_REVISION}, + extractions={EXTRACTION.extraction_id: EXTRACTION}, + existing_nodes={existing.node_id: existing}, + current_base_commit=BASE_COMMIT, + ) + assert report.status == "needs-review" + assert not report.eligible_for_auto_accept + assert "POSSIBLE_DUPLICATE_ENTITY" in {finding.code for finding in report.findings} + + +def test_entity_duplicate_detection_respects_project_scope() -> None: + entity = NODE.model_copy( + update={"node_type": NodeType.ENTITY, "title": "The Dome", "aliases": ("Planetarium",)} + ) + unrelated = NODE.model_copy( + update={ + "node_type": NodeType.ENTITY, + "node_id": "video:existing-entity", + "project_id": "interactive-video", + "title": "Planetarium", + } + ) + report = validate_proposal( + proposal(node=entity, source=None), + projects=PROJECTS, + policies=POLICIES, + sources={SOURCE_REVISION.revision_id: SOURCE_REVISION}, + extractions={EXTRACTION.extraction_id: EXTRACTION}, + existing_nodes={unrelated.node_id: unrelated}, + current_base_commit=BASE_COMMIT, + ) + assert "POSSIBLE_DUPLICATE_ENTITY" not in {finding.code for finding in report.findings} + + +def test_entity_duplicate_review_uses_only_readable_projects_in_explicit_scope() -> None: + entity = NODE.model_copy( + update={"node_type": NodeType.ENTITY, "title": "The Dome", "aliases": ("Planetarium",)} + ) + other_entity = NODE.model_copy( + update={ + "node_type": NodeType.ENTITY, + "node_id": "video:existing-entity", + "project_id": "interactive-video", + "title": "Planetarium", + } + ) + existing = {other_entity.node_id: other_entity} + candidate = proposal( + node=entity, + source=None, + scope=Scope(projects=("dome-performance", "interactive-video")), + ) + readable = validate_proposal( + candidate, + projects=PROJECTS, + policies=POLICIES, + sources={SOURCE_REVISION.revision_id: SOURCE_REVISION}, + extractions={EXTRACTION.extraction_id: EXTRACTION}, + existing_nodes=existing, + current_base_commit=BASE_COMMIT, + readable_project_ids=frozenset({"interactive-video"}), + ) + unreadable = validate_proposal( + candidate, + projects=PROJECTS, + policies=POLICIES, + sources={SOURCE_REVISION.revision_id: SOURCE_REVISION}, + extractions={EXTRACTION.extraction_id: EXTRACTION}, + existing_nodes=existing, + current_base_commit=BASE_COMMIT, + ) + assert "POSSIBLE_DUPLICATE_ENTITY" in {finding.code for finding in readable.findings} + assert "POSSIBLE_DUPLICATE_ENTITY" not in {finding.code for finding in unreadable.findings} + + +def test_entity_duplicate_review_does_not_expand_beyond_proposal_scope() -> None: + entity = NODE.model_copy( + update={"node_type": NodeType.ENTITY, "title": "The Dome", "aliases": ("Planetarium",)} + ) + shared_entity = NODE.model_copy( + update={ + "node_type": NodeType.ENTITY, + "node_id": "shared:planetarium-entity", + "project_id": "shared", + "title": "Planetarium", + } + ) + report = validate_proposal( + proposal( + node=entity, + source=None, + scope=Scope(projects=("dome-performance",), include_shared=False), + ), + projects=PROJECTS, + policies=POLICIES, + sources={SOURCE_REVISION.revision_id: SOURCE_REVISION}, + extractions={EXTRACTION.extraction_id: EXTRACTION}, + existing_nodes={shared_entity.node_id: shared_entity}, + current_base_commit=BASE_COMMIT, + readable_project_ids=frozenset({"shared"}), + ) + assert "POSSIBLE_DUPLICATE_ENTITY" not in {finding.code for finding in report.findings} + + +def test_requested_shared_scope_does_not_reveal_unreadable_shared_entity_names() -> None: + entity = NODE.model_copy( + update={"node_type": NodeType.ENTITY, "title": "The Dome", "aliases": ("Planetarium",)} + ) + shared_entity = NODE.model_copy( + update={ + "node_type": NodeType.ENTITY, + "node_id": "shared:planetarium-entity", + "project_id": "shared", + "title": "Planetarium", + } + ) + report = validate_proposal( + proposal(node=entity, source=None), + projects=PROJECTS, + policies=POLICIES, + sources={SOURCE_REVISION.revision_id: SOURCE_REVISION}, + extractions={EXTRACTION.extraction_id: EXTRACTION}, + existing_nodes={shared_entity.node_id: shared_entity}, + current_base_commit=BASE_COMMIT, + ) + assert "POSSIBLE_DUPLICATE_ENTITY" not in {finding.code for finding in report.findings} + + explicitly_readable = validate_proposal( + proposal(node=entity, source=None, scope=Scope(projects=("dome-performance", "shared"))), + projects=PROJECTS, + policies=POLICIES, + sources={SOURCE_REVISION.revision_id: SOURCE_REVISION}, + extractions={EXTRACTION.extraction_id: EXTRACTION}, + existing_nodes={shared_entity.node_id: shared_entity}, + current_base_commit=BASE_COMMIT, + readable_project_ids=frozenset({"shared"}), + ) + assert "POSSIBLE_DUPLICATE_ENTITY" in {finding.code for finding in explicitly_readable.findings} + + +def test_changed_assertion_and_old_assessment_are_rejected() -> None: + changed_text = "The installation hosted 401 visitors during the 2026 season." + stale_assessment = CLAIM.assessment + evidence = CLAIM.evidence[0].model_copy( + update={"assertion_digest": assertion_digest(changed_text)} + ) + changed_claim = CLAIM.model_copy( + update={"text": changed_text, "evidence": (evidence,), "assessment": stale_assessment} + ) + changed_node = NODE.model_copy(update={"claims": (changed_claim,)}) + report = _validate(proposal(node=changed_node, source=None)) + assert report.status == "invalid" + assert "ASSESSMENT_STALE" in {finding.code for finding in report.findings} + + +def test_assumption_and_limitation_claims_use_the_same_evidence_contract() -> None: + assumption_text = CLAIM.text + assumption_digest = assertion_digest(assumption_text) + assumption_evidence = EVIDENCE.model_copy( + update={ + "assertion_path": "assumptions[0].text", + "assertion_digest": assumption_digest, + } + ) + assumption = CLAIM.model_copy( + update={ + "claim_id": "dome:assumption-seasonality", + "text": assumption_text, + "evidence": (assumption_evidence,), + "assessment": CLAIM.assessment.model_copy( + update={ + "assertion_digest": assumption_digest, + "evidence_digests": (evidence_digest(assumption_evidence),), + } + ), + } + ) + limitation_evidence = EVIDENCE.model_copy( + update={ + "assertion_path": "limitations[0].text", + "assertion_digest": assumption_digest, + } + ) + limitation = assumption.model_copy( + update={ + "claim_id": "dome:limitation-seasonality", + "evidence": (limitation_evidence,), + "assessment": assumption.assessment.model_copy( + update={"evidence_digests": (evidence_digest(limitation_evidence),)} + ), + } + ) + node = NODE.model_copy(update={"assumptions": (assumption,), "limitations": (limitation,)}) + report = _validate(proposal(node=node, source=None)) + assert report.eligible_for_auto_accept, report.findings + + wrong_location = assumption_evidence.model_copy( + update={ + "quote": "400 visitors", + "locator": assumption_evidence.locator.model_copy(update={"start": 0, "end": 13}), + } + ) + invalid_assumption = assumption.model_copy( + update={ + "evidence": (wrong_location,), + "assessment": assumption.assessment.model_copy( + update={"evidence_digests": (evidence_digest(wrong_location),)} + ), + } + ) + invalid = _validate( + proposal(node=NODE.model_copy(update={"assumptions": (invalid_assumption,)}), source=None) + ) + assert "EVIDENCE_MISMATCH" in {finding.code for finding in invalid.findings} + assert any( + finding.path == "operations[0].node.assumptions[0].evidence[0]" + for finding in invalid.findings + ) + + +def test_missing_policy_and_explicit_scope_escape_do_not_accept() -> None: + no_shared_policy = {key: value for key, value in POLICIES.items() if key != "shared"} + candidate = proposal() + report = validate_proposal( + candidate, + projects=PROJECTS, + policies=no_shared_policy, + sources={SOURCE_REVISION.revision_id: SOURCE_REVISION}, + extractions={EXTRACTION.extraction_id: EXTRACTION}, + existing_nodes={}, + current_base_commit=BASE_COMMIT, + ) + assert report.status == "needs-review" + assert "POLICY_MISSING" in {finding.code for finding in report.findings} + + outside_scope = proposal( + node=NODE.model_copy(update={"project_id": "interactive-video"}), + source=None, + scope=Scope(projects=("dome-performance",)), + ) + report = _validate(outside_scope) + assert report.status == "invalid" + assert "OUT_OF_SCOPE" in {finding.code for finding in report.findings} + + +def test_policy_cannot_waive_required_support_review() -> None: + weakened_policy = POLICIES["dome-policy"].model_copy( + update={"require_supported_assessment": False} + ) + report = validate_proposal( + proposal(), + projects=PROJECTS, + policies={**POLICIES, "dome-policy": weakened_policy}, + sources={SOURCE_REVISION.revision_id: SOURCE_REVISION}, + extractions={EXTRACTION.extraction_id: EXTRACTION}, + existing_nodes={}, + current_base_commit=BASE_COMMIT, + ) + assert report.status == "invalid" + assert not report.eligible_for_auto_accept + assert "POLICY_WEAKENING" in {finding.code for finding in report.findings} + + +def test_changed_base_is_a_conflict_and_edge_policy_is_explicit() -> None: + report = _validate(proposal(base_commit="f" * 64)) + assert report.status == "conflict" + assert "BASE_CONFLICT" in {finding.code for finding in report.findings} + + +def test_relationship_evidence_is_bound_to_the_edge_and_exact_source_span() -> None: + policies = { + **POLICIES, + "dome-policy": POLICIES["dome-policy"].model_copy( + update={"automatic_edge_types": ("causes",)} + ), + } + edge = TypedEdge( + edge_id="edge-pilot", + edge_type="causes", + source=EdgeEndpoint(kind="node", id=NODE.node_id), + target=EdgeEndpoint(kind="node", id="dome:other-node"), + evidence=(EVIDENCE,), + ) + bound_evidence = EVIDENCE.model_copy(update={"assertion_digest": edge_assertion_digest(edge)}) + edge = edge.model_copy(update={"evidence": (bound_evidence,)}) + base = proposal(source=None) + candidate = base.model_copy( + update={ + "operations": (*base.operations, ProposalOperation(operation="add_edge", edge=edge)), + "payload_digest": "0" * 64, + } + ) + candidate = candidate.model_copy(update={"payload_digest": proposal_payload_digest(candidate)}) + report = validate_proposal( + candidate, + projects=PROJECTS, + policies=policies, + sources={SOURCE_REVISION.revision_id: SOURCE_REVISION}, + extractions={EXTRACTION.extraction_id: EXTRACTION}, + existing_nodes={ + "dome:other-node": NODE.model_copy(update={"node_id": "dome:other-node"}), + }, + current_base_commit=BASE_COMMIT, + ) + assert report.status == "eligible", report.findings + + tampered = bound_evidence.model_copy( + update={"assertion_digest": assertion_digest("unrelated claim")} + ) + invalid_edge = edge.model_copy(update={"evidence": (tampered,)}) + invalid = candidate.model_copy( + update={ + "operations": ( + *base.operations, + ProposalOperation(operation="add_edge", edge=invalid_edge), + ), + "payload_digest": "0" * 64, + } + ) + invalid = invalid.model_copy(update={"payload_digest": proposal_payload_digest(invalid)}) + invalid_report = validate_proposal( + invalid, + projects=PROJECTS, + policies=policies, + sources={SOURCE_REVISION.revision_id: SOURCE_REVISION}, + extractions={EXTRACTION.extraction_id: EXTRACTION}, + existing_nodes={}, + current_base_commit=BASE_COMMIT, + ) + assert invalid_report.status == "invalid" + assert "EVIDENCE_ASSERTION_MISMATCH" in {finding.code for finding in invalid_report.findings} + + +def test_contradiction_bundle_preserves_both_supported_claims() -> None: + import hashlib + + from gewu.domain.contracts import EdgeEndpoint + + first_quote = "The seasonal audience count was 400." + second_quote = "The seasonal audience count was 450." + content = f"Field report: {first_quote}\n\nVendor report: {second_quote}\n".encode() + source = SOURCE_REVISION.model_copy( + update={ + "artifact_sha256": hashlib.sha256(content).hexdigest(), + "byte_length": len(content), + } + ) + extraction = TextMarkdownExtractor().extract(source.revision_id, content, "text/markdown") + + def node_with_claim(node_id: str, claim_id: str, quote: str, title: str) -> KnowledgeNode: + block = next(item for item in extraction.blocks if quote in item.normalized_text) + start = block.normalized_text.index(quote) + evidence = EVIDENCE.model_copy( + update={ + "source_revision_id": source.revision_id, + "extraction_id": extraction.extraction_id, + "quote": quote, + "locator": Locator( + kind="text_span", block_id=block.block_id, start=start, end=start + len(quote) + ), + "assertion_digest": assertion_digest(quote), + } + ) + assessment = CLAIM.assessment.model_copy( + update={ + "assertion_digest": assertion_digest(quote), + "evidence_digests": (evidence_digest(evidence),), + } + ) + claim = CLAIM.model_copy( + update={ + "claim_id": claim_id, + "text": quote, + "evidence": (evidence,), + "assessment": assessment, + } + ) + return NODE.model_copy( + update={ + "node_id": node_id, + "title": title, + "summary": quote, + "claims": (claim,), + "value": 400 if "400" in quote else 450, + } + ) + + first = node_with_claim( + "dome:count-field-report", "dome:claim-count-400", first_quote, "Field count" + ) + second = node_with_claim( + "dome:count-vendor-report", "dome:claim-count-450", second_quote, "Vendor count" + ) + contradiction = TypedEdge( + edge_id="dome:counts-conflict", + edge_type="contradicts", + source=EdgeEndpoint(kind="node", id=first.node_id), + target=EdgeEndpoint(kind="node", id=second.node_id), + conditions="Both reports refer to the same seasonal audience count.", + interpretation_rationale="The source-backed counts differ for the same reporting period.", + ) + base = proposal(node=None, source=source) + candidate = base.model_copy( + update={ + "operations": ( + *base.operations, + ProposalOperation(operation="upsert_node", node=first), + ProposalOperation(operation="upsert_node", node=second), + ProposalOperation(operation="add_edge", edge=contradiction), + ), + "payload_digest": "0" * 64, + } + ) + candidate = candidate.model_copy(update={"payload_digest": proposal_payload_digest(candidate)}) + report = validate_proposal( + candidate, + projects=PROJECTS, + policies={ + **POLICIES, + "dome-policy": POLICIES["dome-policy"].model_copy( + update={"automatic_edge_types": ("contradicts",)} + ), + }, + sources={}, + extractions={extraction.extraction_id: extraction}, + existing_nodes={}, + current_base_commit=BASE_COMMIT, + ) + + assert report.status == "eligible", report.findings + assert tuple( + operation.node.node_id for operation in candidate.operations if operation.node is not None + ) == (first.node_id, second.node_id) + assert first.claims[0].text == first_quote + assert second.claims[0].text == second_quote + assert any(operation.edge == contradiction for operation in candidate.operations) diff --git a/tests/test_pack_installer.py b/tests/test_pack_installer.py new file mode 100644 index 0000000..8326734 --- /dev/null +++ b/tests/test_pack_installer.py @@ -0,0 +1,200 @@ +from __future__ import annotations + +import importlib.util +import json +from pathlib import Path + +import pytest + +INSTALLER_PATH = Path(__file__).resolve().parents[1] / "agents/gewu/scripts/install.py" +SPEC = importlib.util.spec_from_file_location("gewu_pack_installer", INSTALLER_PATH) +assert SPEC is not None and SPEC.loader is not None +installer = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(installer) + + +def make_pack(tmp_path: Path) -> Path: + pack = tmp_path / "pack" + (pack / "scripts").mkdir(parents=True) + (pack / "manifest.json").write_text('{"pack":"fixture"}\n', encoding="utf-8") + (pack / "AGENTS.md").write_text("fixture profile\n", encoding="utf-8") + return pack + + +def test_install_and_uninstall_preserve_unrelated_workspace_and_mcp_config(tmp_path: Path) -> None: + workspace = tmp_path / "workspace" + workspace.mkdir() + (workspace / "AGENTS.md").write_bytes(b"Existing project guidance\nKeep it.\n") + (workspace / "CLAUDE.md").write_bytes(b"Existing Claude instructions without final newline") + (workspace / ".gemini").mkdir() + mcp_config = workspace / ".gemini/settings.json" + original_mcp = b'{"mcpServers":{"other":{"httpUrl":"https://example.test/mcp"}}}\n' + mcp_config.write_bytes(original_mcp) + (workspace / ".agents").mkdir() + (workspace / ".agents/skills").mkdir() + (workspace / ".agents/skills/custom.md").write_text("user skill", encoding="utf-8") + original_agents = (workspace / "AGENTS.md").read_bytes() + original_claude = (workspace / "CLAUDE.md").read_bytes() + + installer.install(workspace, make_pack(tmp_path), "integrated") + assert (workspace / ".agents/gewu/AGENTS.md").is_file() + assert original_agents in (workspace / "AGENTS.md").read_bytes() + assert original_claude in (workspace / "CLAUDE.md").read_bytes() + assert (workspace / "GEMINI.md").is_file() + assert mcp_config.read_bytes() == original_mcp + assert (workspace / ".agents/skills/custom.md").read_text() == "user skill" + + installer.uninstall(workspace) + assert (workspace / "AGENTS.md").read_bytes() == original_agents + assert (workspace / "CLAUDE.md").read_bytes() == original_claude + assert not (workspace / "GEMINI.md").exists() + assert mcp_config.read_bytes() == original_mcp + assert (workspace / ".agents/skills/custom.md").read_text() == "user skill" + assert not (workspace / ".agents/gewu").exists() + assert not (workspace / ".agents/gewu-install.json").exists() + + +def test_uninstall_refuses_modified_owned_entry_and_preserves_everything(tmp_path: Path) -> None: + workspace = tmp_path / "workspace" + workspace.mkdir() + (workspace / "AGENTS.md").write_text("Project rules\n", encoding="utf-8") + installer.install(workspace, make_pack(tmp_path)) + (workspace / "AGENTS.md").write_text("User edit after installation\n", encoding="utf-8") + + with pytest.raises(ValueError, match="edited routing block"): + installer.uninstall(workspace) + assert "User edit after installation" in (workspace / "AGENTS.md").read_text() + assert (workspace / ".agents/gewu/manifest.json").is_file() + assert (workspace / ".agents/gewu-install.json").is_file() + + +def test_uninstall_preserves_edits_outside_routing_block_and_user_pack_files( + tmp_path: Path, +) -> None: + workspace = tmp_path / "workspace" + workspace.mkdir() + root_instructions = workspace / "AGENTS.md" + root_instructions.write_text("Project rules\n", encoding="utf-8") + installer.install(workspace, make_pack(tmp_path)) + root_instructions.write_text( + "Updated project rules\n" + root_instructions.read_text(encoding="utf-8"), + encoding="utf-8", + ) + custom = workspace / ".agents/gewu/user-notes.txt" + custom.write_text("user-owned", encoding="utf-8") + + installer.uninstall(workspace) + assert "Updated project rules" in root_instructions.read_text(encoding="utf-8") + assert custom.read_text(encoding="utf-8") == "user-owned" + assert (workspace / ".agents/gewu").is_dir() + assert not (workspace / ".agents/gewu/AGENTS.md").exists() + + +def test_install_refuses_occupied_gewu_destination_without_overwriting(tmp_path: Path) -> None: + workspace = tmp_path / "workspace" + (workspace / ".agents/gewu").mkdir(parents=True) + marker = workspace / ".agents/gewu/keep.txt" + marker.write_text("user data", encoding="utf-8") + with pytest.raises(FileExistsError): + installer.install(workspace, make_pack(tmp_path)) + assert marker.read_text() == "user data" + + +def test_install_rollback_restores_original_routes_if_pack_copy_fails( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + workspace = tmp_path / "workspace" + workspace.mkdir() + original = b"Existing instructions\n" + (workspace / "AGENTS.md").write_bytes(original) + pack = make_pack(tmp_path) + real_atomic_write = installer._atomic_write + + def failing_write(path: Path, content: bytes) -> None: + if path.name == "CLAUDE.md": + raise OSError("fixture failure") + real_atomic_write(path, content) + + monkeypatch.setattr(installer, "_atomic_write", failing_write) + with pytest.raises(OSError, match="fixture failure"): + installer.install(workspace, pack) + assert (workspace / "AGENTS.md").read_bytes() == original + assert not (workspace / "CLAUDE.md").exists() + assert not (workspace / "GEMINI.md").exists() + assert not (workspace / ".agents/gewu").exists() + assert not (workspace / ".agents/gewu-install.json").exists() + + +def test_racing_installer_does_not_delete_the_other_installation( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + workspace = tmp_path / "workspace" + workspace.mkdir() + pack = make_pack(tmp_path) + original_mkdir = Path.mkdir + + def competing_create( + path: Path, + mode: int = 0o777, + parents: bool = False, + exist_ok: bool = False, + ) -> None: + if path == workspace / ".agents/gewu": + original_mkdir(path, mode=mode, parents=parents, exist_ok=True) + (path / "other-installation.txt").write_text("keep", encoding="utf-8") + raise FileExistsError("another installer won the race") + original_mkdir(path, mode=mode, parents=parents, exist_ok=exist_ok) + + monkeypatch.setattr(Path, "mkdir", competing_create) + with pytest.raises(FileExistsError, match="another installer"): + installer.install(workspace, pack) + assert (workspace / ".agents/gewu/other-installation.txt").read_text() == "keep" + assert not (workspace / "AGENTS.md").exists() + + +def test_uninstall_rejects_traversal_in_edited_state_before_deleting_files( + tmp_path: Path, +) -> None: + workspace = tmp_path / "workspace" + workspace.mkdir() + outside = workspace.parent / "keep.txt" + outside.write_text("user data", encoding="utf-8") + installer.install(workspace, make_pack(tmp_path)) + + state_path = workspace / ".agents/gewu-install.json" + state = json.loads(state_path.read_text(encoding="utf-8")) + state["files"]["../../keep.txt"] = installer.digest(b"user data") + state_path.write_text(json.dumps(state), encoding="utf-8") + + with pytest.raises(ValueError, match="file list is invalid"): + installer.uninstall(workspace) + assert outside.read_text(encoding="utf-8") == "user data" + assert (workspace / ".agents/gewu/manifest.json").is_file() + + +def test_uninstall_rejects_unrecognized_route_names_in_edited_state(tmp_path: Path) -> None: + workspace = tmp_path / "workspace" + workspace.mkdir() + installer.install(workspace, make_pack(tmp_path)) + + state_path = workspace / ".agents/gewu-install.json" + state = json.loads(state_path.read_text(encoding="utf-8")) + state["routes"]["../../keep.txt"] = state["routes"].pop("AGENTS.md") + state_path.write_text(json.dumps(state), encoding="utf-8") + + with pytest.raises(ValueError, match="routes are invalid"): + installer.uninstall(workspace) + assert (workspace / "AGENTS.md").is_file() + + +def test_uninstall_rejects_agents_symlink_before_reading_state(tmp_path: Path) -> None: + workspace = tmp_path / "workspace" + external = tmp_path / "external" + workspace.mkdir() + external.mkdir() + (external / "gewu-install.json").write_text("not a state file", encoding="utf-8") + (workspace / ".agents").symlink_to(external, target_is_directory=True) + + with pytest.raises(ValueError, match=".agents must not be a symlink"): + installer.uninstall(workspace) + assert (external / "gewu-install.json").read_text(encoding="utf-8") == "not a state file" diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..3dc73a9 --- /dev/null +++ b/uv.lock @@ -0,0 +1,2241 @@ +version = 1 +revision = 5 +requires-python = ">=3.12, <3.15" +resolution-markers = [ + "python_full_version >= '3.14'", + "python_full_version < '3.14'", +] + +[[package]] +name = "annotated-doc" +version = "0.0.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5a/8e/38aa427ed5402449e226975b649c5dc73ccadfefeb95e6aecb8f8ea4b6b6/annotated_doc-0.0.5.tar.gz", hash = "sha256:c7e58ce09192557605d8bbd92836d7e1d520ac9580096042c0bfd197efacf1bb", size = 10758, upload-time = "2026-07-28T13:50:58.129Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3e/30/e900b21425a860e195f32e37657aa1f7c7f2b1bfb26f03ca209b90933c06/annotated_doc-0.0.5-py3-none-any.whl", hash = "sha256:117bac03a25ede5df5440e855b32d556049ca169ead221505badf432fed4b101", size = 5302, upload-time = "2026-07-28T13:50:57.239Z" }, +] + +[[package]] +name = "annotated-types" +version = "0.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5f/56/a8120250d128bed162cd73c76d45f6ef9991f3e068f62a8ee060afa3104a/annotated_types-0.8.0.tar.gz", hash = "sha256:13b2beaad985e05e2d6407ee4c4f35590b11f8d693a258a561055cac8f64cab7", size = 15893, upload-time = "2026-07-23T20:16:13.995Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/99/91/8acff4f5e50511b911bbccb72b8628a49c68ce14148cd9f6431094859a90/annotated_types-0.8.0-py3-none-any.whl", hash = "sha256:f072f4d804ea359e4eaf198b1af7a8b0943881a87f31bb764f8bf219bb9419e0", size = 13427, upload-time = "2026-07-23T20:16:12.938Z" }, +] + +[[package]] +name = "anyio" +version = "4.15.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a9/d2/f4d173e22df740bc37b1db102b386ba719b66e95b0f0d751f556b387e6d2/anyio-4.15.1.tar.gz", hash = "sha256:9f28306018cbd6d329e64a36d58256edff76dd996fe423bc957326e578b82a94", size = 276966, upload-time = "2026-09-05T10:42:39.44Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/12/b8/4bd346e22b28902df4d651910f5242c28d84e4a5c2435ca5c3f797ed7e2e/anyio-4.15.1-py3-none-any.whl", hash = "sha256:6152fdbbf9a77fdec97731721bebf7c4c44f7c29b424b0065826173efc7ed101", size = 132079, upload-time = "2026-09-05T10:42:37.923Z" }, +] + +[[package]] +name = "argon2-cffi" +version = "25.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "argon2-cffi-bindings" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0e/89/ce5af8a7d472a67cc819d5d998aa8c82c5d860608c4db9f46f1162d7dab9/argon2_cffi-25.1.0.tar.gz", hash = "sha256:694ae5cc8a42f4c4e2bf2ca0e64e51e23a040c6a517a85074683d3959e1346c1", size = 45706, upload-time = "2025-06-03T06:55:32.073Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4f/d3/a8b22fa575b297cd6e3e3b0155c7e25db170edf1c74783d6a31a2490b8d9/argon2_cffi-25.1.0-py3-none-any.whl", hash = "sha256:fdc8b074db390fccb6eb4a3604ae7231f219aa669a2652e0f20e16ba513d5741", size = 14657, upload-time = "2025-06-03T06:55:30.804Z" }, +] + +[[package]] +name = "argon2-cffi-bindings" +version = "26.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0b/43/bb8b6e8708d49a5ab36781333af092d9f483b198a2710d01281204640055/argon2_cffi_bindings-26.1.0.tar.gz", hash = "sha256:63505c71542a44b68b1e38060450fb006404170da375feb31af153e7f9c6205d", size = 1790807, upload-time = "2026-08-20T07:44:22.492Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e7/d2/0ae991f1b2181e5be49007c574710a800ad36c2978683addb3e67c474e55/argon2_cffi_bindings-26.1.0-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:21ca0396fe5ec995dd54431c32698189666f9224810acfa752e50d2bd94d9df2", size = 25521, upload-time = "2026-08-20T07:32:43.019Z" }, + { url = "https://files.pythonhosted.org/packages/7e/e4/ad91d8297638aa2258aad4501c306aca99480dfe76ccd638173fa3702db9/argon2_cffi_bindings-26.1.0-cp310-abi3-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:78de2d65e0b9ea7ce9d1b1c3e87297b2d7305a02c266ee2a2d6910daddd7ee69", size = 27177, upload-time = "2026-08-20T07:32:44.158Z" }, + { url = "https://files.pythonhosted.org/packages/6f/86/5363df11b86d02cf3662208e7406496327649cc90eb365bf6f4e8a54a41f/argon2_cffi_bindings-26.1.0-cp310-abi3-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:27f1821903e2ceadcb88ec2b45ef190897b7682449c772f4d9b53e42c520cf29", size = 26597, upload-time = "2026-08-20T07:32:45.172Z" }, + { url = "https://files.pythonhosted.org/packages/f4/b5/a14dcc592652347dad23ee93b278a4da5d2a25c9ed3ebd10d68eea823a4f/argon2_cffi_bindings-26.1.0-cp310-abi3-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d88e5f7e60f28ae0b0cc6b2f16c43e87cd642a196a86f85e0d8bb6fe016fc16d", size = 27403, upload-time = "2026-08-20T07:32:46.13Z" }, + { url = "https://files.pythonhosted.org/packages/b3/81/b4a20d4902af7f796390bf9245ff83c5217dfa7367efa1d14986956c482b/argon2_cffi_bindings-26.1.0-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:34b7d9c24a4165a2c61cc8ae11d44d48c9ce2830fb536cb7914e11fdd9962728", size = 27132, upload-time = "2026-08-20T07:32:47.13Z" }, + { url = "https://files.pythonhosted.org/packages/7e/1b/c8de358af07b1c490e0fcb863ef98e46ddb486e45567aca5a60bd68d9daa/argon2_cffi_bindings-26.1.0-cp310-abi3-musllinux_1_2_riscv64.whl", hash = "sha256:224865cbbcb7a2bd1356741dff12b0134df726b6d44bb7b500df8e303cbd9e81", size = 27588, upload-time = "2026-08-20T07:32:48.087Z" }, + { url = "https://files.pythonhosted.org/packages/48/2f/7ee62a6e79f9309f9d9982d301b22a00010adb580c05c8109b94d7b33de0/argon2_cffi_bindings-26.1.0-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:ffff613aaa9ce6236766e2fc6dc560bb5abde7a2e2416e3db1f9ae395a2b4dd4", size = 26785, upload-time = "2026-08-20T07:32:48.977Z" }, + { url = "https://files.pythonhosted.org/packages/e9/10/960d0ee93d4897741bcaf4799c697dae2d81499f66fd1ed042a7dd54c1f4/argon2_cffi_bindings-26.1.0-cp310-abi3-win32.whl", hash = "sha256:a86c069c91a747a2c4e5c51473590aeb48172fff9b2130d23729a42d98665ecb", size = 23898, upload-time = "2026-08-20T07:32:50.114Z" }, + { url = "https://files.pythonhosted.org/packages/6d/3a/0cc14a05810e6add9bce5e87693334baa2222de5f647fa31781885b6573f/argon2_cffi_bindings-26.1.0-cp310-abi3-win_amd64.whl", hash = "sha256:2c36ff87b5dfaa477d0bd51e9d7f6abdae7c8955d2983c97419085d842154b3e", size = 25730, upload-time = "2026-08-20T07:32:51.091Z" }, + { url = "https://files.pythonhosted.org/packages/4e/db/d83cf2af140547f0b9cdaece05b2dc2dcbf991be4667331d073eff771435/argon2_cffi_bindings-26.1.0-cp310-abi3-win_arm64.whl", hash = "sha256:f9c4420a7a864fe1b86ce35befc95b8e39fb852493b81cf798671ddc265de638", size = 24478, upload-time = "2026-08-20T07:32:52.111Z" }, + { url = "https://files.pythonhosted.org/packages/bb/5f/f652055e18d2627e2eed94c7f31a792127cfe38df786635395d742321674/argon2_cffi_bindings-26.1.0-cp313-cp313-pyemscripten_2025_0_wasm32.whl", hash = "sha256:af11ac37a7c53dc16cb7950a6190851b0870fe218b6c60c0bb7ac355234e3083", size = 15434, upload-time = "2026-08-20T07:32:53.143Z" }, + { url = "https://files.pythonhosted.org/packages/76/38/de696045960f5b846d428c0fb6c130ed3da87aac2af209b05c193815404c/argon2_cffi_bindings-26.1.0-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:db0fcd827ca61622a01b220aadfbece01939acf53888f2cb98cd93e9b1e2c97e", size = 15449, upload-time = "2026-08-20T07:32:54.075Z" }, + { url = "https://files.pythonhosted.org/packages/91/0a/c25af768f6b75a5a71e31207f87c540656b2808c015260444a22763221ad/argon2_cffi_bindings-26.1.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:28524438cd3e723f25412f63d4fd516ff5bae9ae5aa56acbe2a1404398a0cf31", size = 25683, upload-time = "2026-08-20T07:32:55.05Z" }, + { url = "https://files.pythonhosted.org/packages/a8/7e/be212c751ab0bcea7f646615f933bf262e8e50b3f7bef32f861d0a2d066b/argon2_cffi_bindings-26.1.0-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ac82fc756a446b6ccd7139ce70efa9d8bbe541e7ad579a12dcb52764b7175c5f", size = 27311, upload-time = "2026-08-20T07:32:56.166Z" }, + { url = "https://files.pythonhosted.org/packages/a6/ee/f84b28e4afd13d3cac36c1d8fa8c239d2dc2c51cd978d02ee5d5ad98d9bb/argon2_cffi_bindings-26.1.0-cp314-cp314t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6a4e68eed961a8de6928d1c17ff3dc2a547e0e923c17f8f1cd79fb7bc9502f98", size = 26771, upload-time = "2026-08-20T07:32:57.206Z" }, + { url = "https://files.pythonhosted.org/packages/21/c3/95c07a023691ecd529da9cb6a8f0779e13ebc1bdfaa86d145fdc1c6e7e79/argon2_cffi_bindings-26.1.0-cp314-cp314t-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:151dfaad9de753f4af2a7854e707e4784f2acc434340ade64239c5b104b2d605", size = 27568, upload-time = "2026-08-20T07:32:58.361Z" }, + { url = "https://files.pythonhosted.org/packages/e6/31/3a18e31406d8694b4d6a31573c3e572fff6bed318bb744453eb653766d22/argon2_cffi_bindings-26.1.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:061a6919145bbf282ebf1f9c59d3135d4833c25313c8595c0d68cf7712ddfce2", size = 27280, upload-time = "2026-08-20T07:32:59.343Z" }, + { url = "https://files.pythonhosted.org/packages/0b/39/d4be4577e178b2397aa5b5575c8a309bf0da2afe05fe0c72c8f398662d63/argon2_cffi_bindings-26.1.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:62ff20cd130c956c7c9144d5fe35228f98b51c579b2439e988b27ef93e16c02a", size = 27776, upload-time = "2026-08-20T07:33:00.325Z" }, + { url = "https://files.pythonhosted.org/packages/71/47/78f4dd96f7411339f723b96fe24039c1bd5835102b8a5ba71ac4ec712ac7/argon2_cffi_bindings-26.1.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:19423e5d7ac1cc354baab59eaabf18db2ec04ef6593b5abe5a34f323c4a8f87a", size = 26932, upload-time = "2026-08-20T07:33:01.272Z" }, + { url = "https://files.pythonhosted.org/packages/3b/cd/96bfd37434cc0a848a9066c291d84b28846c4c9ea289ed9866b1164d622b/argon2_cffi_bindings-26.1.0-cp314-cp314t-win32.whl", hash = "sha256:4f84cdd868978d7b7350a566c254042d44216d9e37f241f3a6d3b1dfebeede35", size = 24878, upload-time = "2026-08-20T07:33:02.189Z" }, + { url = "https://files.pythonhosted.org/packages/f1/42/d8b6810abd9b1bd2f47ebbccf460da59c9f32e94888bea4f7b137d998797/argon2_cffi_bindings-26.1.0-cp314-cp314t-win_amd64.whl", hash = "sha256:2b741888c93147444fdfc851abd81cc207f37f7f7da42062a00deb3888e57da8", size = 26656, upload-time = "2026-08-20T07:33:03.222Z" }, + { url = "https://files.pythonhosted.org/packages/a9/d1/095d95eaf2ed1d9f77268cf3291bde148c6cd56121f8db2c74c1ba618a0e/argon2_cffi_bindings-26.1.0-cp314-cp314t-win_arm64.whl", hash = "sha256:6ab674f668d5962a3a4136ae0812519b0f1586874263723a32181d60d64137e1", size = 25378, upload-time = "2026-08-20T07:33:04.332Z" }, +] + +[[package]] +name = "attrs" +version = "26.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9a/8e/82a0fe20a541c03148528be8cac2408564a6c9a0cc7e9171802bc1d26985/attrs-26.1.0.tar.gz", hash = "sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32", size = 952055, upload-time = "2026-03-19T14:22:25.026Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309", size = 67548, upload-time = "2026-03-19T14:22:23.645Z" }, +] + +[[package]] +name = "certifi" +version = "2026.7.22" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a3/c2/24167ea9858356b47a87a50d39908bfdb72ceeefe0041586e704e5376b3a/certifi-2026.7.22.tar.gz", hash = "sha256:741e2c3b351ddf169a738da9f2c048608ff7f2c5cc02f1ebc6b118bb090d5d55", size = 138112, upload-time = "2026-07-22T03:35:12.644Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/a7/71ac2cff56fec219ed242bb11b8efb69fcc4bec75db06fb7bfe35de520e6/certifi-2026.7.22-py3-none-any.whl", hash = "sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775", size = 136983, upload-time = "2026-07-22T03:35:11.276Z" }, +] + +[[package]] +name = "cffi" +version = "2.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pycparser", marker = "implementation_name != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9e/ef/008a1939e372c06329a3fce4279c02f328488f3526744906eeec3da7ad5f/cffi-2.1.1.tar.gz", hash = "sha256:dd31f52ea1086513bb9df30f8fcee9b8918323ae067a3d5b78bc826a000712be", size = 530807, upload-time = "2026-08-03T21:21:18.939Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/10/69/43965eccfdead3b9220015fd1320e117be8c6ed01a62ffab76eeb752f5d5/cffi-2.1.1-cp312-cp312-macosx_10_15_x86_64.whl", hash = "sha256:c8c69575568085ba0b1b10c0249d779a214aea6f6522e949a0fc9fb0fcb449d0", size = 184821, upload-time = "2026-08-03T21:19:44.887Z" }, + { url = "https://files.pythonhosted.org/packages/54/7d/16e5a096677b5e313ca80cd5e5170efa3ea44624a82bb111925522da64b1/cffi-2.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f81b3b8f3d4e343550fa4baa0e479bba9f2d29ce9c2e9b51d1ce1718d7442fcf", size = 184719, upload-time = "2026-08-03T21:19:46.129Z" }, + { url = "https://files.pythonhosted.org/packages/56/e6/8941622732edec876dd17d0453dce07317ae96db34f2ec1436c9d3785986/cffi-2.1.1-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:811bd1e21d32de12efca32393a0ab3f5133b54fce9bd44b8bd77ab07da14bf6a", size = 214799, upload-time = "2026-08-03T21:19:47.218Z" }, + { url = "https://files.pythonhosted.org/packages/44/de/f98430906df1545ffde0d543dd124a7a439bc2cd32b36b9c53f805df7333/cffi-2.1.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:68e62fe11f30d5ca8289242866f0a5291402d8529ca2178ab8afc5c9694ae890", size = 222389, upload-time = "2026-08-03T21:19:48.331Z" }, + { url = "https://files.pythonhosted.org/packages/6a/5b/717f1526b9957b34456313c31645c5b82b8fb5c3fe9e4752999be7128bfc/cffi-2.1.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:4a7c934f7360e8cd64fe9efadcbd10c7c6364f531e432b9a4bf5ccbc9e0e8b50", size = 210249, upload-time = "2026-08-03T21:19:49.543Z" }, + { url = "https://files.pythonhosted.org/packages/64/b3/f8aa4f3e34986c7e4ec45072d1b1b9dd295b6b18007b45518d79726dd725/cffi-2.1.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:3143d81e29e1e20a9ce10901ec369012947876596f75a222235965f2b7ae832e", size = 208775, upload-time = "2026-08-03T21:19:50.918Z" }, + { url = "https://files.pythonhosted.org/packages/b1/db/dceb9dd5b231e1da801793f8acc9f3c52a7e1afe40bb1aae37e02b0faad5/cffi-2.1.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c1453022f490d2459a11819d83ad1d586e9ff65a12ac3e705ffebd46d3685dcf", size = 221822, upload-time = "2026-08-03T21:19:52.054Z" }, + { url = "https://files.pythonhosted.org/packages/a0/d2/6cd24ae3be000a634109c247d1475d62e5616d0dc78c82770942ec384248/cffi-2.1.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:208f941bb9d18e768138677f0a6d2ce01f590df56043dda1df1535ac57c88517", size = 225232, upload-time = "2026-08-03T21:19:53.109Z" }, + { url = "https://files.pythonhosted.org/packages/cb/52/3fa190537004dd7f0ab860a6dc7c0175b8667f68d1e618a46f5498d30250/cffi-2.1.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:210019b6c7cf07f081b4c54635c8cf744377001350e29cc0f81c4377b4797735", size = 223597, upload-time = "2026-08-03T21:19:54.515Z" }, + { url = "https://files.pythonhosted.org/packages/80/fb/0bb75b7039588c074b37ae99f40d9bfddf990ecb2fbc346ebccd2e56b9be/cffi-2.1.1-cp312-cp312-win32.whl", hash = "sha256:046bfc24911b37851ee1b51aab8bffe713d89c68c6a057b09484ce9fd5f69b4e", size = 175292, upload-time = "2026-08-03T21:19:55.566Z" }, + { url = "https://files.pythonhosted.org/packages/d9/79/615cc094e2fb508cade7de88d3b4f6c4ec2bab695c97bce9153dc65aadf5/cffi-2.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:f53e442b08449d42821fa4a4fba000095af9f62742a500f978a9f557ec44339a", size = 185919, upload-time = "2026-08-03T21:19:56.89Z" }, + { url = "https://files.pythonhosted.org/packages/70/c6/d0ea84713fe46b243a436a18fcd47d639732747e21635c8a27191b06dc30/cffi-2.1.1-cp312-cp312-win_arm64.whl", hash = "sha256:7bde5e4cc5c10140859842b9d383af292b22639a4dffb725314baf45968cef80", size = 180093, upload-time = "2026-08-03T21:19:58.155Z" }, + { url = "https://files.pythonhosted.org/packages/9d/f4/035513d4117049066b4779dc3b7c0c0fdad175fa13731c9f4003f1cd1478/cffi-2.1.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:b5bdfd1c873d4e093aabc0ca84c4ca6dbc4f752afb5c86f146d9742580c9da2e", size = 194248, upload-time = "2026-08-03T21:19:59.399Z" }, + { url = "https://files.pythonhosted.org/packages/76/af/2aeb4dbb5fc41a04161ae9ff1518de7cec08e164f44a8ce6a4cf7fd2cd1d/cffi-2.1.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:31348097ff5bbe827ccc41795d4dd099d9f0625e7def00ee653c137a490c2a6c", size = 196908, upload-time = "2026-08-03T21:20:00.746Z" }, + { url = "https://files.pythonhosted.org/packages/a7/46/2e5fdde8555706dd98139a910ca11be02809f3f605ce956f655d0214e100/cffi-2.1.1-cp313-cp313-macosx_10_15_x86_64.whl", hash = "sha256:9d2055050ea716bd38b7f7f1579c275386646b4894c155a3e2f3cd62ed41b7c6", size = 184805, upload-time = "2026-08-03T21:20:02.02Z" }, + { url = "https://files.pythonhosted.org/packages/55/41/4c7042f317b9217502988f0873af87e16ad606dc20f84e546e3e6ce9764c/cffi-2.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:19ee6127ee34de7d83ce3d371ebc5ed91addbdcc39f9ab15ce4eb35a4e534971", size = 184764, upload-time = "2026-08-03T21:20:03.141Z" }, + { url = "https://files.pythonhosted.org/packages/43/1f/1c3d90d91811c8f86ced9ed637956c54bfe5b79ca98fe976d7f8c8979f6b/cffi-2.1.1-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:6a8dddef476fab96d066d578fc88526767b836ab5ab21754e1d5bf3879c31c7c", size = 214722, upload-time = "2026-08-03T21:20:04.377Z" }, + { url = "https://files.pythonhosted.org/packages/37/6f/3b5ce4c3b2192d250f04908f2bfd91ef34552ec8f7716a5d4abdb8d67bb2/cffi-2.1.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f16c709686a78c727bbbf059f92b0bf41c6fc60deec706d2dc19f529175a6125", size = 222369, upload-time = "2026-08-03T21:20:05.544Z" }, + { url = "https://files.pythonhosted.org/packages/02/10/4b3c75dde3d9663c9e02ba05c2668b954f671d4bbe346413ca8c696b295a/cffi-2.1.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:fcd22650c908d7b7da162bbfaab594a1227a15d1643a98c68b122ac642fa2264", size = 210175, upload-time = "2026-08-03T21:20:06.75Z" }, + { url = "https://files.pythonhosted.org/packages/df/62/14f74b9543e605d17701dc797b815958b8bb70b7624ce1b832ddad48ed6c/cffi-2.1.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:aa9511c62d14da7aacc9b4bf51f3f697a621e83b2d6919008243c3aad168eea3", size = 208670, upload-time = "2026-08-03T21:20:08.04Z" }, + { url = "https://files.pythonhosted.org/packages/95/95/86342356ff5953b3fb06f7ef7c5bee212d45e770abc7218d451b9148313c/cffi-2.1.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a931079504ecc49efed7744c476a5c343a92fabf66dec2db95edb1b2fdc770e2", size = 221824, upload-time = "2026-08-03T21:20:09.274Z" }, + { url = "https://files.pythonhosted.org/packages/eb/ff/7b3429ff53aafe931ed8a5fc69f481bbef7ba6de87ddcbb63d08f483f613/cffi-2.1.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a2d7755bef5a12ed488f4ef1f1b69ee9191d7396083b755a5d2295f6edb4768b", size = 225148, upload-time = "2026-08-03T21:20:10.7Z" }, + { url = "https://files.pythonhosted.org/packages/34/34/a95870b9221e09cf4f2ce3178b1a210abdfe63a1bd357da940418d7b8d15/cffi-2.1.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e0bcb7e0f677f543555d2adff3bf19c05f66cdb4796e5ff602442ab2fe3c4ef7", size = 223564, upload-time = "2026-08-03T21:20:12.165Z" }, + { url = "https://files.pythonhosted.org/packages/70/ea/839b50531021a647fb5e929f72cf97bc1ff702b5472166164b5b6e76b851/cffi-2.1.1-cp313-cp313-win32.whl", hash = "sha256:334644fbac4eff73d985a17a91226df55d0f394160c4cfb880e084c8f7161cac", size = 175263, upload-time = "2026-08-03T21:20:13.559Z" }, + { url = "https://files.pythonhosted.org/packages/60/a6/8b149b2c3f2e11aaa1618ef64500b45f50f22c57a977a4dff1aff1f91042/cffi-2.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:1aa5645c30469b09530c4ebca77ebf8f17618293c58f8549cb1a543a50236e7d", size = 185688, upload-time = "2026-08-03T21:20:14.69Z" }, + { url = "https://files.pythonhosted.org/packages/01/9a/11f687cb39d6a3504060d5242f04f48c735afb4d3d533958a20594890cb2/cffi-2.1.1-cp313-cp313-win_arm64.whl", hash = "sha256:63bbfd5ded17c4840ac07cd8f1c21ba9d9708141f840b324f422f41b207e3973", size = 180078, upload-time = "2026-08-03T21:20:15.917Z" }, + { url = "https://files.pythonhosted.org/packages/d3/7b/d6bbf82b8b96e7391438898c42f5bd96dd02030fd5b64937d248220003e2/cffi-2.1.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:7dbb61fe3a7699468030f71bbe5f8a0e326a151daa91beb11a6fc1f980c55e1c", size = 194064, upload-time = "2026-08-03T21:20:17.148Z" }, + { url = "https://files.pythonhosted.org/packages/94/e6/bcc91b283be94735e268487a054004f0aa19947b6348fa367db53230abc8/cffi-2.1.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:f24fb43132a4c6b4cb4eb029492919b2db645be6808d738f244fd146c03c32cb", size = 196720, upload-time = "2026-08-03T21:20:18.268Z" }, + { url = "https://files.pythonhosted.org/packages/d9/99/c4b0c17cacdc9c3b8f280026286a9826d6a208c0f047591a3c3ce99b91fd/cffi-2.1.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:d28630f5854ab07ab1fd4aba756de52326c82e6be15d414b12793f1975048b54", size = 184964, upload-time = "2026-08-03T21:20:19.708Z" }, + { url = "https://files.pythonhosted.org/packages/b3/a9/9db617d05d7367c1ad0ab00b3aa6e6f9281edd689b4ee9ea0e5a84e89c97/cffi-2.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:661c298b4821edebead0c91edd2b00374d67ad7c5a1f7a91d4442633b79d6a72", size = 184962, upload-time = "2026-08-03T21:20:20.833Z" }, + { url = "https://files.pythonhosted.org/packages/67/b8/b42132ca113dc567d37684437b46ca1dafc885902b02a110a02d5b511857/cffi-2.1.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:58acb8ab8e295e6c5ea12f888cbb13cf21511ef2a3303a23f4325c29d17fe5c1", size = 222328, upload-time = "2026-08-03T21:20:22.118Z" }, + { url = "https://files.pythonhosted.org/packages/80/10/c5c0cbf0a657aecf59ef511409734230bf556f05a0d6c9eed7aa5c0a0166/cffi-2.1.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:456a61fa52d579ebf9df2e9552ead5129855dbaff6c1e5a9b1bc408809bdc062", size = 209985, upload-time = "2026-08-03T21:20:23.401Z" }, + { url = "https://files.pythonhosted.org/packages/d5/6c/bfa0b87b03b9238148beca990292843c9396ba069b54496596594173de7b/cffi-2.1.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:a4f00aa42f75d6e4595e8866e748cc1705adc0cddfeb2ca86d0d03993d63ba03", size = 208530, upload-time = "2026-08-03T21:20:24.628Z" }, + { url = "https://files.pythonhosted.org/packages/e9/02/4e7d553a7ac4b4238b38b3c1b80d486e9d4436f8d2acbf87a0997fe3f402/cffi-2.1.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b0431303acaea1089ad4b3e9ce4e6518193def1118d4073ca848635ee4ea2e96", size = 221525, upload-time = "2026-08-03T21:20:25.758Z" }, + { url = "https://files.pythonhosted.org/packages/82/1d/a4aaf9babd75acb4d5f223bff71533bee748dd770a382619a798960ee9ba/cffi-2.1.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:64faea20f4e2613363a1a9b9c7dd73058f3ecd00133a511e72ad7c511658f527", size = 225053, upload-time = "2026-08-03T21:20:26.985Z" }, + { url = "https://files.pythonhosted.org/packages/81/10/5dc0e7bdd18e22107054288283380fc97a06ae3f1656a106908d666a3c88/cffi-2.1.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5c58fe613dc5e5336357eff555824a314d8e43282600435c8d1cb6a7a2fedd13", size = 223213, upload-time = "2026-08-03T21:20:28.277Z" }, + { url = "https://files.pythonhosted.org/packages/0b/e9/d0061c364cde06ee43168a0d076ac1da512cbc380d44767b844ba34fe2b6/cffi-2.1.1-cp314-cp314-win32.whl", hash = "sha256:1a18a57b58cfb21fc28d72e876acf10eaed67a1ed96226f92af4df681d571c4c", size = 177682, upload-time = "2026-08-03T21:20:44.288Z" }, + { url = "https://files.pythonhosted.org/packages/a7/06/1c3e01e3ba14c39f6d10bfbac52753b7e22259e38088e5cfe1d704918690/cffi-2.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:3222ba5d678f80a030e6afbcc33dc1ae5cb45facabb61cee2c7016b8432fde48", size = 187949, upload-time = "2026-08-03T21:20:45.623Z" }, + { url = "https://files.pythonhosted.org/packages/87/5b/da4e39efe18eeb89cf580ea9cfc66b6a7c3eadb808fc0cc1d3a295cb5a5d/cffi-2.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:ab36d55f9ed2d067327667c2fea18dda018eb628dd6347aa01dda6cf1f5d3836", size = 182947, upload-time = "2026-08-03T21:20:46.955Z" }, + { url = "https://files.pythonhosted.org/packages/23/59/40338bf421c5accea1d45158170c87006ef1cd371b05c077e76476949728/cffi-2.1.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:7750c6449dff7864bb9bb27ddfb0267756189201a3afc911d82b3caacd70dfc3", size = 188504, upload-time = "2026-08-03T21:20:29.495Z" }, + { url = "https://files.pythonhosted.org/packages/7d/47/5ecf1023850036e674c77ec4de86182d309ae344e39e7cba984b7df5d647/cffi-2.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0beceaabe56af686895136a2de78db54ecd8e4046b236b8fd6d6cb61389e9bf2", size = 188259, upload-time = "2026-08-03T21:20:31.291Z" }, + { url = "https://files.pythonhosted.org/packages/2a/9c/92934c3bea9f785b23eba304538c0b4d37a2a96d2431eb3a1bc87a11aa19/cffi-2.1.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:49cbc70e6542d4ccccb936558d1064a8012541e78f821f955cff24e357776c94", size = 223864, upload-time = "2026-08-03T21:20:32.571Z" }, + { url = "https://files.pythonhosted.org/packages/4d/45/ba4c93527bc38616a8bd36488acb69a2212d60486794f0c1f318949bbb76/cffi-2.1.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:e2d65b31f36619cda3999b78b2aa9632e76b78448e7a56fc4240824200e7c4fc", size = 211538, upload-time = "2026-08-03T21:20:33.808Z" }, + { url = "https://files.pythonhosted.org/packages/80/e9/b6ef565e452acb932fb0cb5443f44a78efbd1233e566f02b5a83855e9115/cffi-2.1.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:28907ab9bfb6aa13184cfc17c6b8e1023c5ab6fd7076d8c20a35e59fe04f8f29", size = 210688, upload-time = "2026-08-03T21:20:34.974Z" }, + { url = "https://files.pythonhosted.org/packages/9a/95/eff5f0cee78d2eabc7eebffec40d3fc1876b5f3c95582e018bb4b99601f2/cffi-2.1.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:51b31d1c98274844cfd7838ce00bfc27c7423a4dc00fc0772fc3331c2cc90676", size = 223803, upload-time = "2026-08-03T21:20:36.564Z" }, + { url = "https://files.pythonhosted.org/packages/fa/01/579d39fb8bef00a335a23d83757b44feb24cd6345a2c451b64cb67b9c362/cffi-2.1.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5e7cecbaadb83884793e05828cee59b210b24583b9c7425d0ba6a754fe22eb4e", size = 226763, upload-time = "2026-08-03T21:20:37.816Z" }, + { url = "https://files.pythonhosted.org/packages/8d/b0/0b44f47c60b01b57b6e2bbd92343f13a85a1d93bc46ccf6e47e244acd99c/cffi-2.1.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:25792eac27877609e7bb06d42ff88278a6624fff2ba9bbb523c09616b117e80f", size = 225688, upload-time = "2026-08-03T21:20:38.959Z" }, + { url = "https://files.pythonhosted.org/packages/eb/d2/3b7176cb570a1d3e27faf67b72f591af508036e0d8b2be2ef9af9e8c84bb/cffi-2.1.1-cp314-cp314t-win32.whl", hash = "sha256:8ef53b2de9bcb9197d31854256575d59dbac0cba72ac627bb291ef5eceb74be4", size = 182868, upload-time = "2026-08-03T21:20:40.388Z" }, + { url = "https://files.pythonhosted.org/packages/56/78/31f00c1bcd97c9bbf55f1bfdf5bc809a5de8887473e90bb9960dca825e80/cffi-2.1.1-cp314-cp314t-win_amd64.whl", hash = "sha256:616f097f2fe415bc92a247f02e11f634e1f9e9a83d327e3c915c15089c87869e", size = 194104, upload-time = "2026-08-03T21:20:41.725Z" }, + { url = "https://files.pythonhosted.org/packages/7b/1b/58496f2ed0a35de575250c02a43ab3cc2c04d494a88fed31c1cabc0fd176/cffi-2.1.1-cp314-cp314t-win_arm64.whl", hash = "sha256:ad2c86c495b899d862ea0f4b42891b8713a3bd45dd4105c7fd51c2a72f39f3a5", size = 186402, upload-time = "2026-08-03T21:20:43.042Z" }, +] + +[[package]] +name = "click" +version = "8.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c7/0e/7fa0ef50764b67090eca4114772a2abf8b6148198475e54c660b97caeee6/click-8.5.0.tar.gz", hash = "sha256:ba0d2089de75ea0310e2dde03160e6ca10009947fb95a182f9b54021bb272e34", size = 382235, upload-time = "2026-08-26T13:33:14.56Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/58/50/6c0d534c5f134586a8e1ba4e330569e32f057e33372ae556463212fb4cd3/click-8.5.0-py3-none-any.whl", hash = "sha256:255bc9599cf7748b4b1a446ccc735421bd08a2ae529a8b88597d3de5664ee360", size = 125251, upload-time = "2026-08-26T13:33:12.928Z" }, +] + +[[package]] +name = "cloudpickle" +version = "3.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/27/fb/576f067976d320f5f0114a8d9fa1215425441bb35627b1993e5afd8111e5/cloudpickle-3.1.2.tar.gz", hash = "sha256:7fda9eb655c9c230dab534f1983763de5835249750e85fbcef43aaa30a9a2414", size = 22330, upload-time = "2025-11-03T09:25:26.604Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/39/799be3f2f0f38cc727ee3b4f1445fe6d5e4133064ec2e4115069418a5bb6/cloudpickle-3.1.2-py3-none-any.whl", hash = "sha256:9acb47f6afd73f60dc1df93bb801b472f05ff42fa6c84167d25cb206be1fbf4a", size = 22228, upload-time = "2025-11-03T09:25:25.534Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "cryptography" +version = "50.0.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9d/af/182eb91b0df3fe75c4d9f26fe70684569566745f6ba7e5c9c73a862c5252/cryptography-50.0.2.tar.gz", hash = "sha256:7b46165bb56eb4704e2eaaf86f3c940d19154535d9b0ca7d6d590b04060e00d5", size = 880623, upload-time = "2026-09-30T15:30:04.884Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e5/56/d194340cc4a57535e82e1bee9e89667ac4b7c13b5d3f59686deae3094dd5/cryptography-50.0.2-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:fa8f5efb344d6908a1ce62f4a24e2e5780f825d6f53f5f50ec5ffacac72936cb", size = 3914904, upload-time = "2026-09-30T14:43:44.339Z" }, + { url = "https://files.pythonhosted.org/packages/d9/69/c9bd862c3bf43d6399c433caf002df16e2dffd4be49bdf515cda38038711/cryptography-50.0.2-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:79def8d059362e7831389ed3be0ecdf58a89386e1271e35dd9f5af84e81bffd0", size = 4731146, upload-time = "2026-09-30T14:43:47.113Z" }, + { url = "https://files.pythonhosted.org/packages/21/69/64cef1f702bf6657e0cc186ed1a2891d50d29fb41586b254e1c07adea261/cryptography-50.0.2-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:630ebfea3bf689d075f82316324ff7433dc447fe6bc1bfc76524b74b4a9567d2", size = 4719841, upload-time = "2026-09-30T14:43:49.01Z" }, + { url = "https://files.pythonhosted.org/packages/38/6b/61a3f8d8c5e1e49a6cddccafc4015cc1c0021360ab0acb4080e7a423644a/cryptography-50.0.2-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:f9f6143a8c75945eb960d9eb98905a441394abfa24afaae239d514ffb2586480", size = 4738340, upload-time = "2026-09-30T14:43:50.932Z" }, + { url = "https://files.pythonhosted.org/packages/7b/2e/7212ca32fd43dc91f2f41db20160b268098874b4c9a0e7be94d6835f5b2e/cryptography-50.0.2-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:a582ab2ae1d34f67112cadc86702774c9ea4374df6bca6afe672817203c99134", size = 5367029, upload-time = "2026-09-30T14:43:52.911Z" }, + { url = "https://files.pythonhosted.org/packages/1a/f1/b474e930c4d910328780e3940da76f5aa5cbc48ce1fc14e44d239d9ea9db/cryptography-50.0.2-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:4061c0079120205fb760c58acab6443e217307dcf05e3702cf970e0689972856", size = 4753050, upload-time = "2026-09-30T14:43:55.272Z" }, + { url = "https://files.pythonhosted.org/packages/7c/52/9af10e80ac16b0fcc2123f9cbd5e7afbd0fd5075bb7a607c592258a39cda/cryptography-50.0.2-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:ac9ed99d81760c62fe89d5f0815cdfa1ba9a35141cf30f1c2d044f04b4803d2e", size = 4376724, upload-time = "2026-09-30T14:43:57.24Z" }, + { url = "https://files.pythonhosted.org/packages/71/37/6202e488cc1eb625ea110c292c6bda92823176e023f427d8d5660ce8d632/cryptography-50.0.2-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:87e9ce85beb6b328ba370cc6e6aea483c92617b4c95b1d33a49297eb662bfb04", size = 4737859, upload-time = "2026-09-30T14:43:59.541Z" }, + { url = "https://files.pythonhosted.org/packages/8f/30/e86d7d518489b0ae2497091a35287abcb1a2ce4037837a34afbe9b1d6964/cryptography-50.0.2-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:f265528741e048bce55c3463ed721fb0aa45a5888d8add8cfeccb3035451bbdc", size = 5324103, upload-time = "2026-09-30T14:44:01.901Z" }, + { url = "https://files.pythonhosted.org/packages/d3/69/2c833a049475e0a3444e94c7d0aca0aa51d166374a449b09e92ac98138de/cryptography-50.0.2-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:9dab55f57c74c3cad24c323bacbbd04be4705ba6eb0d92e920b1fc4837ed5079", size = 4752576, upload-time = "2026-09-30T14:44:04.545Z" }, + { url = "https://files.pythonhosted.org/packages/6c/5d/906970b83bbfc1f5bbfb677a143c181f2801f23b6a7204a3b47c42c97e65/cryptography-50.0.2-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:25784ce8b9621c90c643efb9e1e2162ab3b0224cae446ad5e70e7fcb1ce18b51", size = 4870819, upload-time = "2026-09-30T14:44:06.884Z" }, + { url = "https://files.pythonhosted.org/packages/68/e3/f2298d3bb55e0c4a91841ec4d01b3f020ba8c5fbf15ccdcc6dcf03f97025/cryptography-50.0.2-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:85d0d9a31b9098e98534226d5686b47264b95e62ce459dc2e62fdfc809f9fe93", size = 5030152, upload-time = "2026-09-30T14:44:09.443Z" }, + { url = "https://files.pythonhosted.org/packages/9a/4f/adfc442765721292fff86d314ce385d3249d22db42295c0dd057727b60f3/cryptography-50.0.2-cp311-abi3-win_amd64.whl", hash = "sha256:7afa5a6602a9f29af1f3a2965f831bae7c9d5d597b7cbb716d41ab3b7d89879c", size = 3824692, upload-time = "2026-09-30T14:44:11.671Z" }, + { url = "https://files.pythonhosted.org/packages/ce/cb/52eb3770c0d0be2702a98c6e96065ddc0a2877cf0845aa9c23397c142cd4/cryptography-50.0.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:f785f6161f202ab04d8ca194158968798e480ca058943907972da5f12e2881e8", size = 3892731, upload-time = "2026-09-30T14:44:13.485Z" }, + { url = "https://files.pythonhosted.org/packages/19/8e/aa1fc533d4546b127b45de8aa024eb5933d23eff9debfe25931e56861095/cryptography-50.0.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:0ecbc5652bdb6fc9eaf89a7d196e20941adfe812f43bc4ca05d9150496821047", size = 4710431, upload-time = "2026-09-30T14:44:15.427Z" }, + { url = "https://files.pythonhosted.org/packages/6a/64/72bc3f75176e7e406b748a3e3830432b8c51297b38368713df04dc04898a/cryptography-50.0.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ab50ee449bf968271e820086f10a33d101dd060370abc10bcd22279be2656539", size = 4694824, upload-time = "2026-09-30T14:44:17.69Z" }, + { url = "https://files.pythonhosted.org/packages/4e/c6/62c77550edfa5ca3f14bf44a1e6739b9fa09d6e998a11d97ed8213bccc98/cryptography-50.0.2-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:a9f7355e6fab51f6c369b86fb7571cffa05edee2c2121e0380a37fb9ac1cd5c1", size = 4716967, upload-time = "2026-09-30T14:44:19.661Z" }, + { url = "https://files.pythonhosted.org/packages/f4/37/cce70f150c432914460157a6ecc161752e053aa5ec0ef3b3f7dc6e31039a/cryptography-50.0.2-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:94e5e9f108ee10471288214d3d233fbfbb492840a8457eb85178d643ddeb32c7", size = 5328676, upload-time = "2026-09-30T14:44:21.744Z" }, + { url = "https://files.pythonhosted.org/packages/aa/9a/6f2f0304d634ceafdeaf23e84537336664ac419b5d07611675c2ad3f6b7a/cryptography-50.0.2-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:241449bf940a5d27309bd317e6f9a2af6932113818bb2b8f5c59ddc7ef16da18", size = 4727698, upload-time = "2026-09-30T14:44:24.178Z" }, + { url = "https://files.pythonhosted.org/packages/1d/de/66bcf9244d118663b2e1aaded8990f4640e3d7b7411870a5765f252074d2/cryptography-50.0.2-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:d8947001be83df1394050758ce0e745dd74fb134eef0a4b5124208dfc3a68c37", size = 4354821, upload-time = "2026-09-30T14:44:26.263Z" }, + { url = "https://files.pythonhosted.org/packages/bd/e6/db28a28c7b6c676addce89136de3d8db49ea825a8c863472e36e42ead4ad/cryptography-50.0.2-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:4a20ce1e5cb4284a86692fdcba7cb8754185c6b2e5c56fcef3751cf451d3cdc2", size = 4716748, upload-time = "2026-09-30T14:44:28.447Z" }, + { url = "https://files.pythonhosted.org/packages/30/96/01546c7f69ea0e2ab790a2e4f0934a4052fb9b388147fbf83c2fd72f1e57/cryptography-50.0.2-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:84f964e537f916e2cc85199e5a88742e964939b575ac8598b3f9d6cc416cdaf1", size = 5285085, upload-time = "2026-09-30T14:44:30.704Z" }, + { url = "https://files.pythonhosted.org/packages/6c/01/03263395f74d50b071e9e66daace3f8bef80493e5d410726f2ba8554736b/cryptography-50.0.2-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:828d49b0ff5a0e3975865571c5d91dbbdd0d38d8289b249a163e9425413a5e05", size = 4727268, upload-time = "2026-09-30T14:44:32.92Z" }, + { url = "https://files.pythonhosted.org/packages/eb/94/2bfe8f29ec0cc9c0d99359c4161adf32858e4934b72c6d100d2ac0bbe962/cryptography-50.0.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:deb9fde5c60e437ee4821bc9bc39ff31b42135c27e1dc61ef0a629389c1de62e", size = 4849503, upload-time = "2026-09-30T14:44:34.969Z" }, + { url = "https://files.pythonhosted.org/packages/54/44/e80651ecbf0e42b62e2bb5f5768916e07eea72e1297338956a61df361f88/cryptography-50.0.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:8c71ba2cd31fc93748c38e1b613200ff1c2665cbfd5341fe3a61cfde35a1430e", size = 5004057, upload-time = "2026-09-30T14:44:37.064Z" }, + { url = "https://files.pythonhosted.org/packages/f8/cc/1d33befb3cd7ea7e77d2d73f43f2066471da1b21f24a6156efcaabf6d2e8/cryptography-50.0.2-cp314-cp314t-win_amd64.whl", hash = "sha256:78198641e5be9521beea5aa782bb551a58068d10e6eb04c9c680c1b69f2e7d45", size = 3795868, upload-time = "2026-09-30T14:44:39.71Z" }, + { url = "https://files.pythonhosted.org/packages/2d/49/93f6a6e7a87c9aa68d44d3e1cdb5fe8f60c90d5d2f46acae9a56892816b8/cryptography-50.0.2-cp315-abi3.abi3t-macosx_11_0_arm64.whl", hash = "sha256:edc3342adf8f697fc5f59c887a304356f147b397809440ed64e2fa6af2f50f37", size = 4133708, upload-time = "2026-09-30T14:44:41.807Z" }, + { url = "https://files.pythonhosted.org/packages/8c/75/32ac2a56243d778805c16ca6a32b8f74fb757df7e28d7ecb560afafb59cf/cryptography-50.0.2-cp315-abi3.abi3t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d370b8d1dfcdf7130178137f6fbee6140774a1acc6cacefc4b42643ec11d0a3a", size = 4956267, upload-time = "2026-09-30T14:44:43.693Z" }, + { url = "https://files.pythonhosted.org/packages/aa/a4/2c8d734e43d97f0842ee9f1b7b4bfb3d0cf5e19edebf43c2afe6675c2320/cryptography-50.0.2-cp315-abi3.abi3t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f2f9bd7f90c64fe89253f0a2c05e3c4856072660429ce8831b4235bf29403a67", size = 4966465, upload-time = "2026-09-30T14:44:45.769Z" }, + { url = "https://files.pythonhosted.org/packages/c2/58/ee288c829a6f41f6235ae9dd33d82fd19b45442b65b4c8a3da36963d9f7a/cryptography-50.0.2-cp315-abi3.abi3t-manylinux_2_28_aarch64.whl", hash = "sha256:e275096ea1e60cc595cda2836fd4a6c725d1125108b868be17f53684d164e2cc", size = 4959356, upload-time = "2026-09-30T14:44:48.211Z" }, + { url = "https://files.pythonhosted.org/packages/92/20/9ded6d51ddd9897f6b6e81fb9ebea7951d7cc5d6c890b0ed8abf77a51a80/cryptography-50.0.2-cp315-abi3.abi3t-manylinux_2_28_ppc64le.whl", hash = "sha256:b13478603dcd0a2479ff8e87e2c19a7d525734686fe3c49542472293a204212d", size = 5548822, upload-time = "2026-09-30T14:44:50.86Z" }, + { url = "https://files.pythonhosted.org/packages/02/a8/8df951850d6b31d2a00218f19e2b3f999523437ed7a819df7fa427942fca/cryptography-50.0.2-cp315-abi3.abi3t-manylinux_2_28_x86_64.whl", hash = "sha256:58a0c478eeca76fe5e07993c5a0703def34a6dc6a0cda4f5564639b33112ffe7", size = 5001199, upload-time = "2026-09-30T14:44:53.379Z" }, + { url = "https://files.pythonhosted.org/packages/8b/f9/36b3022218ce75b7cdf068fb95f809f9bd0d820e4955ef43b90c255cc7ac/cryptography-50.0.2-cp315-abi3.abi3t-manylinux_2_31_armv7l.whl", hash = "sha256:d38cdff612d06fa6a32840d5e1b1f7a27cee4a349aa9085d94a67789d6bfd408", size = 4629333, upload-time = "2026-09-30T14:44:55.635Z" }, + { url = "https://files.pythonhosted.org/packages/8c/72/20f99a219f6af47cdd1cbd978c243b92d71496e168a746138af44ded4f29/cryptography-50.0.2-cp315-abi3.abi3t-manylinux_2_34_aarch64.whl", hash = "sha256:fdd28f912fccfec1846a94e2e1e8f9b0012f557f0c46fe4f3eb0d7a87afcf90b", size = 4958822, upload-time = "2026-09-30T14:44:59.639Z" }, + { url = "https://files.pythonhosted.org/packages/f2/20/196f112617fb08eb4d608a2a6c422373d46f9cc2857f38fc0667033c0899/cryptography-50.0.2-cp315-abi3.abi3t-manylinux_2_34_ppc64le.whl", hash = "sha256:cbc8738fd8526d80f35cb3a40d41f41a2e7030bb3b18b09a6778ef63d291c2fd", size = 5506351, upload-time = "2026-09-30T14:45:02.267Z" }, + { url = "https://files.pythonhosted.org/packages/24/95/83378121ef3eaaaf71d4b781577ff794acb39b9e1b87a3f156898c8497ed/cryptography-50.0.2-cp315-abi3.abi3t-manylinux_2_34_x86_64.whl", hash = "sha256:e105ab60406787da31fccc883fc0f733af1efd78f0136a4599692c4083a73d0c", size = 5000859, upload-time = "2026-09-30T14:45:05.009Z" }, + { url = "https://files.pythonhosted.org/packages/22/f7/70fd7ae4d1dbfa7ba29b02e1b9068771519a86027756510b700ce81086a8/cryptography-50.0.2-cp315-abi3.abi3t-musllinux_1_2_aarch64.whl", hash = "sha256:6f8700550aa1474a91e5dc07049c46f98b423b5b1ddd0483e0b51362eeeaf5be", size = 5092151, upload-time = "2026-09-30T15:29:15.932Z" }, + { url = "https://files.pythonhosted.org/packages/d4/be/688367b74de86984bd58d8efacfc7c9e68b89a6a22ced0fb4f38db50254a/cryptography-50.0.2-cp315-abi3.abi3t-musllinux_1_2_x86_64.whl", hash = "sha256:c71be1cbfa5cd9a41ee452acf1eccd82b2c05950358b106ec8ceb83411d1a020", size = 5286120, upload-time = "2026-09-30T15:29:18.309Z" }, + { url = "https://files.pythonhosted.org/packages/39/d1/55f8a3f2ef5d1529e16835ef10cf0fe3d559ce237b46dddc440c0bba3649/cryptography-50.0.2-cp315-abi3.abi3t-win_amd64.whl", hash = "sha256:c423ab384a46c4dff7217b2ea5ba2e11cffdeab6441acd04cf65a369caf0366c", size = 4111557, upload-time = "2026-09-30T15:29:20.155Z" }, + { url = "https://files.pythonhosted.org/packages/23/ad/ac987755d00e1e64273760228d2635ae38dae2be83e3c6e0d3289d91dec3/cryptography-50.0.2-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:0ec5f09541743261e66e291b4a0cbf0fb2997aeaab6d9e9c740b9dba1b58d1c2", size = 3943588, upload-time = "2026-09-30T15:29:22.265Z" }, + { url = "https://files.pythonhosted.org/packages/d5/8d/6d585339bedf85d45044c85d8412dac53f2bb6f918e8b7777efba1787844/cryptography-50.0.2-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c5e67125c7dca78d199ec4e116aa93dbb83494808ecbb8211a2cb09b1bf41dbd", size = 4756166, upload-time = "2026-09-30T15:29:24.58Z" }, + { url = "https://files.pythonhosted.org/packages/bf/f1/1c1f6874e8550cfddd4b688ceb38cefb6ed15ceed224d56f133f3d88c214/cryptography-50.0.2-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ee247f5c245c9a2fe7c8e2214e295918838e44e00a45a6718451e4004219e767", size = 4749145, upload-time = "2026-09-30T15:29:26.807Z" }, + { url = "https://files.pythonhosted.org/packages/c1/63/61b15dc1a8de03fe0adbe3fd7608b3ad5c73bf50993bbcb1faaa930afe33/cryptography-50.0.2-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:dfe9763530994147d9af1def057a5b9658b00e8f8fe8743d144d1e0911c2e454", size = 4763638, upload-time = "2026-09-30T15:29:28.588Z" }, + { url = "https://files.pythonhosted.org/packages/fc/35/b345bdfa40c9126df1a9d33236aa98418367931b8725f84fc3ae2b98dc59/cryptography-50.0.2-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:58ddb5a8e3179d12f19e4ea34d2d32e9d63a4baa142c875c1eb59f41b7243acd", size = 5382217, upload-time = "2026-09-30T15:29:30.589Z" }, + { url = "https://files.pythonhosted.org/packages/4f/87/ef344a9e616871f2519c22d6afcda79ddd5d35e9592d95eb6e677608d055/cryptography-50.0.2-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:f21e8a22c8605750c7af886bab299a363721264061b4ac0a30efb73cfd58efc5", size = 4781387, upload-time = "2026-09-30T15:29:32.605Z" }, + { url = "https://files.pythonhosted.org/packages/90/5b/f2fdb13cd0b96f6f932c8627bb292a45f11c64d21620a8e120aee9a3b848/cryptography-50.0.2-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:9c8402a82ea0dc4ceeab793db05f0fafa8ca139ca34fcde5df0f596103c74107", size = 4403790, upload-time = "2026-09-30T15:29:34.374Z" }, + { url = "https://files.pythonhosted.org/packages/bc/ce/7e4f662b1e3c393513569e402cfc85ac7da0bd3d5435e122a3140219eb2d/cryptography-50.0.2-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:0ddc924c04591c2811ca024d62ecad4f7f6f08af8939c211438f48a16bd23602", size = 4764319, upload-time = "2026-09-30T15:29:36.149Z" }, + { url = "https://files.pythonhosted.org/packages/3c/3f/86ff33ce34cc0de6847fb96e035a1a760d81652e38643f617c02ad32ef7a/cryptography-50.0.2-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:a6557e5f38e065ca9fbdaf7cfc7435ecb1d113aa81a022d1b51921ee7432e227", size = 5338560, upload-time = "2026-09-30T15:29:39.053Z" }, + { url = "https://files.pythonhosted.org/packages/40/cf/6b5c8e2fd9202d98988ab7cb5cc5c991704c4ad55f492ff408e4969f83f1/cryptography-50.0.2-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:1981f1db4630889b9ef7803fadef12b056f428cb6b85c27ba57b774793b6093c", size = 4780973, upload-time = "2026-09-30T15:29:41.251Z" }, + { url = "https://files.pythonhosted.org/packages/10/bf/8d6ebc7dded797bd0f0160d52188021211f011a2b164ef0ae1dac4587465/cryptography-50.0.2-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:7a8701d6b584d76e909e3d305b7d126b41439876a5aaf76cddc67fc230eafa2e", size = 4897738, upload-time = "2026-09-30T15:29:43.106Z" }, + { url = "https://files.pythonhosted.org/packages/d4/aa/f3f6e0de7e6253b8baa8b2d8fb9d50924fa75cee3d4624bd4bc1208ee923/cryptography-50.0.2-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:ce47f66801c20ec6c6632453bb5960fe38939e9306970b48b3a5a26de7745d94", size = 5058280, upload-time = "2026-09-30T15:29:44.827Z" }, + { url = "https://files.pythonhosted.org/packages/f6/b6/a1faf3a27ae9405fb34b1713cc73b2d8a26b04d5c561578fa2e6ef3e5bb9/cryptography-50.0.2-cp39-abi3-win_amd64.whl", hash = "sha256:4e81d95e5bafc2d6e34e4bed780e53e4d5b9a2f928573428aa4d35fbec1eb0de", size = 3854095, upload-time = "2026-09-30T15:29:46.782Z" }, +] + +[[package]] +name = "cuda-bindings" +version = "13.4.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cuda-pathfinder" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/65/11/1293429c1c3a3e19b551275e65efddd122a905bbe7e368816a59f3ef2a41/cuda_bindings-13.4.3-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bfbd3f7d4ac04dd41dc49121b9e408c8283992f47124c2290ecb79bbbadcca8e", size = 6488226, upload-time = "2026-09-23T02:22:00.578Z" }, + { url = "https://files.pythonhosted.org/packages/b8/c3/efb6bbb7307bf5c83dc4acca650280b210c67ed1a1a60f898a90e7c82e38/cuda_bindings-13.4.3-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d5f72bcfcdf3be23e1da3c792f68f508586f48d037bca8b10f552c4cca5971f2", size = 7172185, upload-time = "2026-09-23T02:22:03.114Z" }, + { url = "https://files.pythonhosted.org/packages/f8/a9/c83eb5aa055a4b0c3776d83f6f88b9e778a6fe0415210977c889c6a0bb8a/cuda_bindings-13.4.3-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d7c6c9f46fca7f3fc61959ef9a2398ac656172145b43f408e0a6492360cf1c0c", size = 6316533, upload-time = "2026-09-23T02:22:09.694Z" }, + { url = "https://files.pythonhosted.org/packages/8a/24/9c01edfd2210737ee9471b47db857a079e5a23f2677e5d9778c0ff23d099/cuda_bindings-13.4.3-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1fd7d8459b364aedc11f3e59703453ced823135f78a9111ca70feef8d56d4d21", size = 6929124, upload-time = "2026-09-23T02:22:11.765Z" }, + { url = "https://files.pythonhosted.org/packages/ab/e6/3c094ef0eb00a7b0ff69a3915327e2c2d14e712ebe471a2217bf7f020f33/cuda_bindings-13.4.3-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4796864ce829bd95ef2ef0d23c6ba21bb64e08f7fab0a377302ed1affb6605c7", size = 6407948, upload-time = "2026-09-23T02:22:18.484Z" }, + { url = "https://files.pythonhosted.org/packages/a3/49/7a3769c43e432b0434dd46424058b47af4347167f0dfca1ecb27e2de92a1/cuda_bindings-13.4.3-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bbacde6f75665b197016b986164cfdaa33b17515e5e635a63ddb75926aaa71c3", size = 6978955, upload-time = "2026-09-23T02:22:20.535Z" }, + { url = "https://files.pythonhosted.org/packages/0a/ca/2c4419ca787278f65faf0f0155791a80fa141f39a628e97e4663e2ba09fa/cuda_bindings-13.4.3-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d6eb969920e28f66f8fc3b0b3afcb6e09381cc96bf8e8158d774e9488ae89980", size = 6314272, upload-time = "2026-09-23T02:22:26.785Z" }, + { url = "https://files.pythonhosted.org/packages/29/9c/f878de5de8e6d1a64d55096539b7b72821e6dc62682d5968e842b95d97df/cuda_bindings-13.4.3-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7e11cfe8fec4c85ce79feda18124971c52596f0cbd642a94f5dafc257124a4b3", size = 6865978, upload-time = "2026-09-23T02:22:29.041Z" }, +] + +[[package]] +name = "cuda-pathfinder" +version = "1.8.3" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b9/fb/f8e1890428f9f590b4beebd63b068aac1ce32a3331510c847b9f9a78f261/cuda_pathfinder-1.8.3-py3-none-any.whl", hash = "sha256:e29e59829c297a7a5233bd9cc71094fc5bddbd076951482670178f9eade39b1f", size = 62561, upload-time = "2026-10-02T03:20:23.712Z" }, +] + +[[package]] +name = "cuda-toolkit" +version = "13.0.3.0" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/c7/a79086a62c98befcdb8349656c6f114e2db3b8b2422f6e25c97a7f2a9a3c/cuda_toolkit-13.0.3.0-py2.py3-none-any.whl", hash = "sha256:d693caaa261214ddd7dbb60d68e71cbed884e68c2be7509778f3051da0b91c3f", size = 2512, upload-time = "2026-04-14T00:50:08.173Z" }, +] + +[package.optional-dependencies] +cublas = [ + { name = "nvidia-cublas", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" }, + { name = "nvidia-cuda-nvrtc", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" }, +] +cudart = [ + { name = "nvidia-cuda-runtime", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" }, +] +cufft = [ + { name = "nvidia-cufft", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" }, + { name = "nvidia-nvjitlink", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" }, +] +cufile = [ + { name = "nvidia-cufile", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" }, +] +cupti = [ + { name = "nvidia-cuda-cupti", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" }, +] +curand = [ + { name = "nvidia-curand", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" }, +] +cusolver = [ + { name = "nvidia-cublas", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" }, + { name = "nvidia-cusolver", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" }, + { name = "nvidia-cusparse", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" }, + { name = "nvidia-nvjitlink", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" }, +] +cusparse = [ + { name = "nvidia-cusparse", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" }, + { name = "nvidia-nvjitlink", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" }, +] +nvjitlink = [ + { name = "nvidia-nvjitlink", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" }, +] +nvrtc = [ + { name = "nvidia-cuda-nvrtc", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" }, +] +nvtx = [ + { name = "nvidia-nvtx", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" }, +] + +[[package]] +name = "fastapi" +version = "0.143.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-doc" }, + { name = "opentelemetry-api" }, + { name = "pydantic" }, + { name = "starlette" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0b/d7/6a8753ab6c1d432dc53703c3e1b92974a94531b7d047c32bbaae461ea844/fastapi-0.143.0.tar.gz", hash = "sha256:1acffe48206a80917cf7dac21992b5c44b25384e8902bf745c1fd9dabcf6c51f", size = 468391, upload-time = "2026-10-08T12:29:46.54Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bd/f4/27e386913417ad32aae42bba48b0c0cce40e9ff2fba1a871ca2702c37324/fastapi-0.143.0-py3-none-any.whl", hash = "sha256:3e9395fd35276425b61b516a31fdd7c77fe2af83e41b4da22e30696fb1304c5d", size = 144665, upload-time = "2026-10-08T12:29:44.853Z" }, +] + +[[package]] +name = "filelock" +version = "4.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/35/c8/1d457d9150ff948f2ce6ada7715e0eeebbe5d3b58a45271a1e222474bcd3/filelock-4.1.1.tar.gz", hash = "sha256:7ba0927482c5a814b0a7f391d029ccdb8010f576f0a74c0dcde1811e8bc4c1b6", size = 563430, upload-time = "2026-10-11T16:11:54.373Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d7/8b/f837f52905395ba4510fe61f753c24833fb0a9c76e21267bb9f828b664a9/filelock-4.1.1-py3-none-any.whl", hash = "sha256:3f4a557945a7b0f95efeb1f432267affe5d45ac8ddde2aed1b97ebb62382c089", size = 132460, upload-time = "2026-10-11T16:11:52.753Z" }, +] + +[[package]] +name = "fsspec" +version = "2026.9.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/77/cd/9be253869fc42e764de7f3dedd6969af7d44ff9c3375214a3442a6f3fc08/fsspec-2026.9.0.tar.gz", hash = "sha256:0f08147951c8cb31d844c3547d631053b127863b60be04cf06e121333ee0e2fe", size = 333545, upload-time = "2026-09-18T17:50:42.825Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6c/c0/a98505f18594f1bce828bb159cec0fcf9860562f1a2c85913409fc8f3d9e/fsspec-2026.9.0-py3-none-any.whl", hash = "sha256:8dd6e646e99ea382bd85f97a45e6b526a442d79423a7dc673f1e2756d05fcb5f", size = 221738, upload-time = "2026-09-18T17:50:41.341Z" }, +] + +[[package]] +name = "gewu" +version = "0.1.0" +source = { editable = "." } +dependencies = [ + { name = "argon2-cffi" }, + { name = "cryptography" }, + { name = "fastapi" }, + { name = "httpx2" }, + { name = "mcp" }, + { name = "portalocker" }, + { name = "pydantic" }, + { name = "pyotp" }, + { name = "pyyaml" }, + { name = "regex" }, + { name = "uvicorn", extra = ["standard"] }, +] + +[package.optional-dependencies] +dev = [ + { name = "httpx" }, + { name = "mypy" }, + { name = "pytest" }, + { name = "regex" }, + { name = "ruff" }, + { name = "sqlite-vec" }, + { name = "types-pyyaml" }, + { name = "types-regex" }, +] +retrieval = [ + { name = "sentence-transformers" }, + { name = "sqlite-vec" }, +] + +[package.dev-dependencies] +dev = [ + { name = "httpx" }, + { name = "mypy" }, + { name = "pytest" }, + { name = "ruff" }, + { name = "sqlite-vec" }, + { name = "types-pyyaml" }, + { name = "types-regex" }, +] + +[package.metadata] +requires-dist = [ + { name = "argon2-cffi", specifier = ">=25.1,<26" }, + { name = "cryptography", specifier = ">=48,<51" }, + { name = "fastapi", specifier = ">=0.115,<1" }, + { name = "httpx", marker = "extra == 'dev'", specifier = ">=0.28,<1" }, + { name = "httpx2", specifier = ">=2.13,<3" }, + { name = "mcp", specifier = ">=2,<3" }, + { name = "mypy", marker = "extra == 'dev'", specifier = ">=1.14,<2" }, + { name = "portalocker", specifier = ">=3.2,<4" }, + { name = "pydantic", specifier = ">=2.10,<3" }, + { name = "pyotp", specifier = ">=2.9,<3" }, + { name = "pytest", marker = "extra == 'dev'", specifier = ">=8.3,<10" }, + { name = "pyyaml", specifier = ">=6,<7" }, + { name = "regex", specifier = ">=2024.11,<2027" }, + { name = "regex", marker = "extra == 'dev'", specifier = ">=2024.11,<2027" }, + { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.9,<1" }, + { name = "sentence-transformers", marker = "extra == 'retrieval'", specifier = "==6.1.0" }, + { name = "sqlite-vec", marker = "extra == 'dev'", specifier = "==0.1.9" }, + { name = "sqlite-vec", marker = "extra == 'retrieval'", specifier = "==0.1.9" }, + { name = "types-pyyaml", marker = "extra == 'dev'", specifier = ">=6,<7" }, + { name = "types-regex", marker = "extra == 'dev'", specifier = ">=2024.11,<2027" }, + { name = "uvicorn", extras = ["standard"], specifier = ">=0.34,<1" }, +] +provides-extras = ["retrieval", "dev"] + +[package.metadata.requires-dev] +dev = [ + { name = "httpx", specifier = ">=0.28,<1" }, + { name = "mypy", specifier = ">=1.14,<2" }, + { name = "pytest", specifier = ">=8.3,<10" }, + { name = "ruff", specifier = ">=0.9,<1" }, + { name = "sqlite-vec", specifier = "==0.1.9" }, + { name = "types-pyyaml", specifier = ">=6,<7" }, + { name = "types-regex", specifier = ">=2024.11,<2027" }, +] + +[[package]] +name = "h11" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, +] + +[[package]] +name = "hf-xet" +version = "1.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9e/27/06d899ea7bd721d272f84aac98bdb238de98af4cc767a69056d967d68c71/hf_xet-1.7.0.tar.gz", hash = "sha256:d406ec79053c0871817f700c2ac8c36ba0d87f9c34b7458b0f0063bb218b0466", size = 985689, upload-time = "2026-10-06T20:18:43.89Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9f/7c/3e45174942e6793adde6cba4daa7fb037275cf02a944d9eadfcf9ff33b86/hf_xet-1.7.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:fa029678be1ba7f953c409b0b27bf15cc69cd1c9b3a674fbd78856ebefca1052", size = 3803919, upload-time = "2026-10-06T20:18:09.844Z" }, + { url = "https://files.pythonhosted.org/packages/ff/3a/5e8b363391adcbb002e191dbf924dab31464ea9c45adfeb73502afc36d35/hf_xet-1.7.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:57bc157b8b7fe3bee9dcb9af7f3da8de41801c3b31a9ef68a77a33c6a6be382f", size = 3553588, upload-time = "2026-10-06T20:18:13.376Z" }, + { url = "https://files.pythonhosted.org/packages/e5/c2/0d1eaa5da13bbf9c896badc7f380601c7d973a87a6ffb4d100267c4536c1/hf_xet-1.7.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:87dab080f8f7d32781c2586904e3603f4e60d09bfc727706c3ae419e0829beeb", size = 4201962, upload-time = "2026-10-06T20:18:16.11Z" }, + { url = "https://files.pythonhosted.org/packages/23/2d/225d5b11a9ca7d31b9470a57f2b2be1a5cef8b84325a2146aeb4589e226c/hf_xet-1.7.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:b01fe18dbbd151a2403d2c64ed30dc6547b00d6babab9a617d77c7acdb81ee66", size = 3982978, upload-time = "2026-10-06T20:18:18.092Z" }, + { url = "https://files.pythonhosted.org/packages/93/34/9d681f0e3dac0b5dae0d7dea748429266f24e52415446523f464fbaa828e/hf_xet-1.7.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:4ee5e05a627f5ab5bad7a86582277d645556ea1e199903aae19e033a392aa13a", size = 4181558, upload-time = "2026-10-06T20:18:20.082Z" }, + { url = "https://files.pythonhosted.org/packages/de/f0/277f039b7d72027bc2ed277f1b62a2f70f740a5aac2a3e7243e5b6854c5d/hf_xet-1.7.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:19c0e64f14175ccb6a1aff69e0d2ab9ec5269a560e6687abaf2b3fa4f73de7cd", size = 4411546, upload-time = "2026-10-06T20:18:21.999Z" }, + { url = "https://files.pythonhosted.org/packages/3d/7f/832d3ddb49326114175b7bcc50daea8565c09fd21ac03a02b211c09fefb7/hf_xet-1.7.0-cp314-cp314t-win_amd64.whl", hash = "sha256:757168feb5679647c0bb13ee5d0faebe799c4dff9051419885a566ebd79f949d", size = 3812809, upload-time = "2026-10-06T20:18:24.288Z" }, + { url = "https://files.pythonhosted.org/packages/3d/c4/310c3c29e5beae7c049e63947bd1923d597883b41c9ec4718589920812c4/hf_xet-1.7.0-cp314-cp314t-win_arm64.whl", hash = "sha256:b91569d5f1b61c34b043687da02c05dd3604f3d329e7868510bf3f7971599006", size = 3646174, upload-time = "2026-10-06T20:18:26.279Z" }, + { url = "https://files.pythonhosted.org/packages/9c/0b/b03be21ffaada749ba0d3197d8aefbf1aa698bac149580421c15239b299e/hf_xet-1.7.0-cp38-abi3-macosx_10_12_x86_64.whl", hash = "sha256:e3e88a7a75d7d95cbee1f37dc31341d6201124cf21c6c4b1dfab8ccba9b09e0f", size = 3796096, upload-time = "2026-10-06T20:18:28.43Z" }, + { url = "https://files.pythonhosted.org/packages/c3/47/a26ebdce7056a61e931f228439bc0ab08cbec239d1690f965e5e637cba79/hf_xet-1.7.0-cp38-abi3-macosx_11_0_arm64.whl", hash = "sha256:59fba37039233c7fcbe196817d6cdcf1b40dfb17b410f229d85b0cf0a1848da4", size = 3560352, upload-time = "2026-10-06T20:18:30.365Z" }, + { url = "https://files.pythonhosted.org/packages/a3/4c/2bf3b66c215d409655f28de1622393dde04c9461280d48c7924bb3b2decd/hf_xet-1.7.0-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2814a6e999d13464c4d679b788cc5d784eb5a4edfc638a31f10e9a11ab531ef8", size = 4212180, upload-time = "2026-10-06T20:18:32.292Z" }, + { url = "https://files.pythonhosted.org/packages/49/0c/a2f703a5a78267556e89e03316fa0805c86b72b50829bc67665746e8ebf0/hf_xet-1.7.0-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:fcfd6c22418e57dd5b3aea649e813b2e2cfb2aebf317b210d90f1fe4b3018b52", size = 3990011, upload-time = "2026-10-06T20:18:34.21Z" }, + { url = "https://files.pythonhosted.org/packages/a4/77/e52e4201b1cbf571530a61cc57f70182045a39a230089ee5f1df182a4de2/hf_xet-1.7.0-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:80f79dae613ce9e0ea1fd1ae15616ca9ac74aed4c770aabc199c4f03ebecc863", size = 4190628, upload-time = "2026-10-06T20:18:36.062Z" }, + { url = "https://files.pythonhosted.org/packages/6c/dc/03a21b89f118664a0926ff25b0f8e44a519bf22724a6a8fc7a9abbc188b6/hf_xet-1.7.0-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:0a9e802f33bf50c851abe45fc5380e61f959e2d369647d6742b79ad9d6c27cab", size = 4418814, upload-time = "2026-10-06T20:18:37.888Z" }, + { url = "https://files.pythonhosted.org/packages/4d/59/b35106dfa71b6eef605dc88bd038fe99c7f86fb132a15b60d0bf2f235b2c/hf_xet-1.7.0-cp38-abi3-win_amd64.whl", hash = "sha256:2b7bb5727889b0f2436dbaaad8fc4c3e66b8240d992716989e0c086b4278b1bc", size = 3822644, upload-time = "2026-10-06T20:18:40.052Z" }, + { url = "https://files.pythonhosted.org/packages/48/cd/072313585f74fe9d441e2eb5e0a4703c30586cd709810ea369675f61b74e/hf_xet-1.7.0-cp38-abi3-win_arm64.whl", hash = "sha256:acc3851cf2576a8fb2ae926da863f4efabe21303cf292e9a44332802ab0dcc6a", size = 3662436, upload-time = "2026-10-06T20:18:42.205Z" }, +] + +[[package]] +name = "httpcore" +version = "1.0.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, +] + +[[package]] +name = "httpcore2" +version = "2.13.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "h11" }, + { name = "truststore" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/cb/f3/1db7aa2bc2524062192bb0e0323969492d1883152a232fe36eea65f4e35c/httpcore2-2.13.1.tar.gz", hash = "sha256:e0aa977abe17e69a3b820a24542a6fa88702676d83880b8d194dcd18408e5103", size = 68071, upload-time = "2026-09-23T07:47:22.372Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/09/ba/a4568248771ce81957bfb7cc600264a40fbcda092391ee1c415c50be4bea/httpcore2-2.13.1-py3-none-any.whl", hash = "sha256:e1e05d4f25f7d7d496bfb96748f6f4b67657b03da069b3a68c36069f3db73d0a", size = 83423, upload-time = "2026-09-23T07:47:19.365Z" }, +] + +[[package]] +name = "httptools" +version = "0.9.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/3a/ec/deed52912ab7ca6c0b12859330c571c60c61d7267b341b28951fcbf13694/httptools-0.9.0.tar.gz", hash = "sha256:d484ebb7e3a3f3597b0f645fbd1b85633674ca808c1f5ba11c2caf7c66f5c8b6", size = 282523, upload-time = "2026-10-09T19:57:04.301Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/da/ed/0916b8b7ebd1deeaf22acba71b68c57b4b6b69aa1918f3812dea208b4276/httptools-0.9.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f9ccc9884241efceb4547a92955d128574c864681f11b7ea3ecbde295fafbe8b", size = 119039, upload-time = "2026-10-09T19:54:32.556Z" }, + { url = "https://files.pythonhosted.org/packages/c2/0b/9b6de4a01a563a904d0826c9069c824b330e1816df26c9bdf93f60b50857/httptools-0.9.0-cp312-cp312-macosx_11_0_x86_64.whl", hash = "sha256:45b3002392948dcf578029c89f6318e1289a993a1a5ec38a4161560fab60f811", size = 115051, upload-time = "2026-10-09T19:54:33.908Z" }, + { url = "https://files.pythonhosted.org/packages/85/3f/642113e9882f53158ecddf58003d25f18ded2c210ed23bf6eb663d4d51c3/httptools-0.9.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:3e3201fe4d46e0d15d7ff9fafc94a605da9eb82d2c5b9837f0368acb325481f1", size = 552848, upload-time = "2026-10-09T19:54:35.434Z" }, + { url = "https://files.pythonhosted.org/packages/95/4c/3ecc59c99c28652d8d08d9b5be65770a14d2cadc616dad94224cee2b0e7e/httptools-0.9.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:58a1b0ec4cbb930e69669f9771715b2c7898d3cdf064d9811f7a66afef96b544", size = 549181, upload-time = "2026-10-09T19:54:37.099Z" }, + { url = "https://files.pythonhosted.org/packages/43/ce/21f5b2759590b7054e38d3b704a3c6b853c3395c370b3d6f16c45aea0fc0/httptools-0.9.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4c58dc91aefb31adad500aa68054334f429b840b36dd29e34e834101044cb2ef", size = 569121, upload-time = "2026-10-09T19:54:38.772Z" }, + { url = "https://files.pythonhosted.org/packages/52/c3/7c523aa8d0fa7a57010a3e1bbdebc209585009076465f3d1ae6a3f54b814/httptools-0.9.0-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6b900073e7b8481ef1aaf4f6c1789d210a1db01a9da8789821578cfeb4c2d540", size = 487649, upload-time = "2026-10-09T19:54:40.398Z" }, + { url = "https://files.pythonhosted.org/packages/94/e2/d90d60002692b8afcbc06fb49ca3a4365b32abed6c40fb2b612c67721a00/httptools-0.9.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:6c12d0393a903b58bc5f5a7406d6c5290acfb8284290d68547ce620c06f7d133", size = 529583, upload-time = "2026-10-09T19:54:42.296Z" }, + { url = "https://files.pythonhosted.org/packages/46/c0/19172874cde0344a20c85877a0b2d0dcfca31111729ad8a79e8b4ac4e207/httptools-0.9.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:29b0d823e3c1e7cd1093a5dc889245db693ef13ada624cd66e2262421ef38867", size = 552414, upload-time = "2026-10-09T19:54:44.19Z" }, + { url = "https://files.pythonhosted.org/packages/1b/b8/02ea7910f69e5371986b025fb3b410592106df54e977a5732fd1d95917b5/httptools-0.9.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:6ebd39ee26db460cfe5ab8b71a15d1149b289139a0d3981522757d6af620887e", size = 482400, upload-time = "2026-10-09T19:54:46.064Z" }, + { url = "https://files.pythonhosted.org/packages/de/97/f05eac916d44cbbfe43668a6a40ab93e7fd8f94d5120d1ce2d8e55c69871/httptools-0.9.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:4efbee349138a3fee7a4cc3a95abd2d499fae70dd5bff9fed9138d6f570f4283", size = 536148, upload-time = "2026-10-09T19:54:47.695Z" }, + { url = "https://files.pythonhosted.org/packages/b6/e9/9435dfcb7f1a1d6ebdc79a902164dfca70e33774c80bb60d25c630c31ef1/httptools-0.9.0-cp312-cp312-win32.whl", hash = "sha256:36fac804b8cfd6b935ae64f71349f833d2b6298404626d017a2c57bb942bc643", size = 86274, upload-time = "2026-10-09T19:54:49.1Z" }, + { url = "https://files.pythonhosted.org/packages/8b/69/813f1bf90be507d4166c437be1a413574d0e0abf36e2fec10c266661b0ee/httptools-0.9.0-cp312-cp312-win_amd64.whl", hash = "sha256:7e32b83bd8c2f8b6fa726ef34e63e21c4d7eddc277d40d4ef7245ea3ed28e5b6", size = 92446, upload-time = "2026-10-09T19:54:50.498Z" }, + { url = "https://files.pythonhosted.org/packages/ae/e0/1d29e328c4cafe843403341e1455e0aec18b0e6910fbb14f12b36b563f19/httptools-0.9.0-cp312-cp312-win_arm64.whl", hash = "sha256:813a32f94991b9627795528053c73a57d2ce3eb98ede89f0e1c7a31095938e81", size = 88676, upload-time = "2026-10-09T19:54:51.844Z" }, + { url = "https://files.pythonhosted.org/packages/9c/04/223994f8589750d2a36ceb43203e739cf75bd9e12c226680d73567766908/httptools-0.9.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:4fb995082fe41ec410b33c48b54fb1d44abb8a6ee762c31e8c42519e8c3a30a9", size = 117115, upload-time = "2026-10-09T19:54:53.356Z" }, + { url = "https://files.pythonhosted.org/packages/31/d8/b4407836e567a862ce79d78a628d785db99aba52e63496d68c60eed0d475/httptools-0.9.0-cp313-cp313-macosx_11_0_x86_64.whl", hash = "sha256:b9cd15cb7cf0d5cc41f649fd789aae12c56c3b83eff593f8e095c1d4555ad5c3", size = 113225, upload-time = "2026-10-09T19:54:54.81Z" }, + { url = "https://files.pythonhosted.org/packages/79/f6/0caa51b077492a7306bdbd9dfb907a2246985f0aed1fe2d086255921848b/httptools-0.9.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:088de1738e1af624466a01c35d652dbe6fb825be887c76d68aa850621d81db88", size = 520112, upload-time = "2026-10-09T19:54:56.3Z" }, + { url = "https://files.pythonhosted.org/packages/fa/da/7a47b7c2106bb10e6d4c04a139d045257a4f93c672fae6f0b9e92b1f7bc2/httptools-0.9.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b1ac7f1bc6c0dbf90684b77571a51a21b2463909fd916ce0ac9bfc4d566dc75", size = 516079, upload-time = "2026-10-09T19:54:57.938Z" }, + { url = "https://files.pythonhosted.org/packages/0f/4d/417b42d2663acf4f5aeb2718dc894ec2be4e3dcfd8caa2d3bf9ee2dce511/httptools-0.9.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b9430f65db521db7962ad951571d446171213686f96c998a54dc18ed574821e2", size = 535040, upload-time = "2026-10-09T19:54:59.769Z" }, + { url = "https://files.pythonhosted.org/packages/cb/de/8df4c09a33ddaf50f697719f20201cf93631ef4b50cec05e42acf179a7c1/httptools-0.9.0-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:52fe0176682a25b15370f23f5b0f1366a84771df89144fb0cd979cb72a94b5ca", size = 462799, upload-time = "2026-10-09T19:55:01.673Z" }, + { url = "https://files.pythonhosted.org/packages/e8/90/1bfe91e3fca29c541d85d7ba8ed92a406d4dd13608c281baf7ec75369fec/httptools-0.9.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:757e3f79cb865a7db94e0db5f4d0ed3284a69e39d53568f433982ea13c60cac1", size = 497596, upload-time = "2026-10-09T19:55:03.201Z" }, + { url = "https://files.pythonhosted.org/packages/b0/af/2bbd5af0dd7a0e0c3b63bfefafd87a07041eb13d7cd710fbf30708b70773/httptools-0.9.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:6ff5f0ed70783dcb9562dbd20edca51c3d4d277f128223709e3da6b75986d1d4", size = 517110, upload-time = "2026-10-09T19:55:05.011Z" }, + { url = "https://files.pythonhosted.org/packages/d4/7a/9f165817c3e27df9098f3d50a675417d8721253f1073434f48a3f9d9a6c2/httptools-0.9.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c0f537e5e8152e8d9cae82804024790cb973061abd3b7ef8f66f46e2b5c7bb51", size = 459198, upload-time = "2026-10-09T19:55:06.985Z" }, + { url = "https://files.pythonhosted.org/packages/93/20/b93279e334946c359d39aaf405241c6fd60f9e60da709bc4156731a4413c/httptools-0.9.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1a7f1df31829c258158be01bb04eb668c4fba7df1ddf2262131a972962e651b6", size = 502996, upload-time = "2026-10-09T19:55:08.733Z" }, + { url = "https://files.pythonhosted.org/packages/86/c9/ac3657943d40c5a9949b72565ee03151e480fb18c062c7c13c0c0276df6f/httptools-0.9.0-cp313-cp313-win32.whl", hash = "sha256:714bf348f468532d86bed670837e7d5ddff3834dd7f5d3c08066da400c86f088", size = 85878, upload-time = "2026-10-09T19:55:10.275Z" }, + { url = "https://files.pythonhosted.org/packages/74/69/d23079cd4bc16d11e49c3f51c2540c018736f26701a2a73183cae9255a1c/httptools-0.9.0-cp313-cp313-win_amd64.whl", hash = "sha256:805b0f2618e5d4c3e28f45b731eb1a0539691ae4a2f97b4ce014de0bf96a1ff5", size = 91549, upload-time = "2026-10-09T19:55:11.701Z" }, + { url = "https://files.pythonhosted.org/packages/0b/ed/5ff678a774b721f054c095f04d84fc536e7369ea4f4c9af3813a518d95b6/httptools-0.9.0-cp313-cp313-win_arm64.whl", hash = "sha256:bfdabac0c6d3d6a5be8c2a100a001c92c14a39bbafd5999545a675c493626e64", size = 88043, upload-time = "2026-10-09T19:55:13.046Z" }, + { url = "https://files.pythonhosted.org/packages/31/39/0965023968452245ece67b161adbf7c5652f8d0697ac69312f9d21849411/httptools-0.9.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1a4050a651e1f2faf05eb028ce9f2168abbcee9e24b209f5c1f2eb96d8c569e4", size = 118142, upload-time = "2026-10-09T19:55:14.491Z" }, + { url = "https://files.pythonhosted.org/packages/31/39/a6ec662d81059e505e953af709797038e83e489014df721e506f4fd0d3c5/httptools-0.9.0-cp314-cp314-macosx_11_0_x86_64.whl", hash = "sha256:130635fea6e611a6b2026120037965ddb88b3dafd11bb64e264b101a70a76630", size = 113943, upload-time = "2026-10-09T19:55:15.887Z" }, + { url = "https://files.pythonhosted.org/packages/72/04/4ecb7251a6c55bef61b157bb93fd44678943c35702a5966e4d5ebda2d450/httptools-0.9.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:18d800aaa2d6bff7d889df810d1b19a5fde72b1f6c0ca96e8d9f28a692fe5460", size = 514397, upload-time = "2026-10-09T19:55:17.48Z" }, + { url = "https://files.pythonhosted.org/packages/31/5a/0c26c98ee06f0f39608de715e7ca868baec942171a77feace5a0ba548ca6/httptools-0.9.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c0e45def4d9ce7073e2226535572442d9d6efb4047c7a5fd8960807e877ce70a", size = 514383, upload-time = "2026-10-09T19:55:19.221Z" }, + { url = "https://files.pythonhosted.org/packages/d4/6c/0f85d4f1f579c49aea6e4946dd304e9f33a680382b5117970ab887885bc7/httptools-0.9.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1f6da814aeecbc6cb8872d6d3e85ed16e8ab1653f9557cea8658725ce212348a", size = 534993, upload-time = "2026-10-09T19:55:20.992Z" }, + { url = "https://files.pythonhosted.org/packages/3b/32/97a836533b7bc9e269fc6d075c2d27669ca9786bf43f229158b9b4b15021/httptools-0.9.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8e1e037bb57dbc549c6fe20370b763ea74bdb09413cdcf857e4f14d9e4e2fb13", size = 461494, upload-time = "2026-10-09T19:55:22.785Z" }, + { url = "https://files.pythonhosted.org/packages/67/cf/a2d5e8dc3bad9b0b966bb546170234b4614275346cccbc01f6cdb6fce3b3/httptools-0.9.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:cd3e55223a77d6e08d5730ebacb4930ecca5d2ce7c57e7ba10833be7e52903f1", size = 495859, upload-time = "2026-10-09T19:55:24.9Z" }, + { url = "https://files.pythonhosted.org/packages/bd/d9/7472c4ca2aa1cfe6d0f9923380784b034cb77addc88589f2e5c92fd3b4df/httptools-0.9.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:beb2c8a34cc90fb4d862b7284eafdb322030d6a8b2ee5eb6a744f84205beedc3", size = 516303, upload-time = "2026-10-09T19:55:26.84Z" }, + { url = "https://files.pythonhosted.org/packages/c1/dd/f9be002ba859714cc306fe86204b7cb12bac091be66a7e23d7bb25d259bb/httptools-0.9.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0cc339a807c156d840b54f8bf050ba0fc265eb81692c24bca8535b52fbd797c6", size = 458188, upload-time = "2026-10-09T19:55:28.571Z" }, + { url = "https://files.pythonhosted.org/packages/89/7a/ed8bb5344071afd12c87e57e8839fa65abc3895b92a5d065be79ecacb919/httptools-0.9.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:b6ee42112d785a913dd63ec0335435a3dddbea5040c151252db815b0095cf066", size = 498356, upload-time = "2026-10-09T19:55:30.301Z" }, + { url = "https://files.pythonhosted.org/packages/04/8d/3f1390c901d4a266ad9d5b988c47c4883e322e6f6cc021c592b9a050fb19/httptools-0.9.0-cp314-cp314-win32.whl", hash = "sha256:d1e329a1866981efe0201d05a374617f6c6cf14434a501d78ab22793d1ab1fa6", size = 88479, upload-time = "2026-10-09T19:55:32.071Z" }, + { url = "https://files.pythonhosted.org/packages/99/05/7de70a4eea3b52d31a95fe64eb5775ccdead01e4913e4741b4424e9ef180/httptools-0.9.0-cp314-cp314-win_amd64.whl", hash = "sha256:edd5aa045fa3cc57143db018dd32ce7962bd5b525d05230709015d7e570100aa", size = 94809, upload-time = "2026-10-09T19:55:33.423Z" }, + { url = "https://files.pythonhosted.org/packages/e8/79/7f6c354a8f8f74381fd473f365d2db3cd976ee8d1422b8dd7455dfc52b62/httptools-0.9.0-cp314-cp314-win_arm64.whl", hash = "sha256:6ff0145b34610e57c9fae20df4e133c8d54266447387de6fcc0bdabfe4db4569", size = 91508, upload-time = "2026-10-09T19:55:34.764Z" }, + { url = "https://files.pythonhosted.org/packages/94/0c/f9e8148ca684b41b4b5d0ced0860530b9a9bcb7c38bf727d83dcbfea42d0/httptools-0.9.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:80eae881cfb69383303e9a4d7961a478025b89c24f38f2e69b30c516fa0d57f2", size = 123684, upload-time = "2026-10-09T19:55:36.445Z" }, + { url = "https://files.pythonhosted.org/packages/3d/54/3c1d910e8f0bc9ee0ba7867b687e3272c8ae4a7da2df2fbf1b2bce77f0f9/httptools-0.9.0-cp314-cp314t-macosx_11_0_x86_64.whl", hash = "sha256:b2ab3aad55d75d0b8df8d8a1b5920baaec9b161112cd5e95984848b4d2cd3dfe", size = 118378, upload-time = "2026-10-09T19:55:37.851Z" }, + { url = "https://files.pythonhosted.org/packages/d4/ce/3b9694880da927ae69b5629b8847cfe73d14584be2aa974a92ed2675b7da/httptools-0.9.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:db735a23ecb0f0450d2b24e0a05fb00a8a35c9db172919c4d3e023e7c7ee4c9b", size = 591295, upload-time = "2026-10-09T19:55:39.501Z" }, + { url = "https://files.pythonhosted.org/packages/3c/89/1ff2835b6adf5c08a477d3a199e72b71e7f26df55ceaaed7d7364d745a1d/httptools-0.9.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:995b52f7c260ac7023640221f27472303968753cb6fc6fce1ddfb0e9db59a398", size = 603709, upload-time = "2026-10-09T19:55:41.404Z" }, + { url = "https://files.pythonhosted.org/packages/24/40/4f59a0d9dca6d60002e7cb5dbf1441b558ced5a65b5b4131d57cbbd7c806/httptools-0.9.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3af4e45ff455fce5511fdf2653c1ce428ef09c56fe37a83eb4d924c2d474f31e", size = 607379, upload-time = "2026-10-09T19:55:43.119Z" }, + { url = "https://files.pythonhosted.org/packages/bf/19/381d444a3ba704cd5c67eb4617ae7a08e920a8239c688f23ba0de07a270b/httptools-0.9.0-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ce8e723b4637034b76f5382a30a6b725518c332273e8d62a6c7d46e90837c947", size = 530031, upload-time = "2026-10-09T19:55:44.85Z" }, + { url = "https://files.pythonhosted.org/packages/e2/c5/c9ba7758bf266240f598934510af4a800edafd9c8eb1fcf15feac0427063/httptools-0.9.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:465bc1526debf53a3be92022a16ca0c38f891ea3b5c1587af4f52e44020f8a07", size = 575129, upload-time = "2026-10-09T19:55:46.536Z" }, + { url = "https://files.pythonhosted.org/packages/db/87/c17f3a53616a3849681f7c8e913ce966487b95038504bbb035c38f5f2fbe/httptools-0.9.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:8463b34ebde3f000627e9dbd8a545f995ad49fbf7ff9dd5abc0cd507da98a603", size = 582996, upload-time = "2026-10-09T19:55:48.545Z" }, + { url = "https://files.pythonhosted.org/packages/88/e3/cb33ba1348ddfa5853f96021f4c38674ac383b92c944492cf7638bd6bfd0/httptools-0.9.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f9489c1d87160c126f73b004742fe8654fa1ce37ed89e9e01330a1c10aaecde4", size = 526150, upload-time = "2026-10-09T19:55:50.261Z" }, + { url = "https://files.pythonhosted.org/packages/e9/00/af0e2f33ba5be60803a492ad377e798714d0c970e76015e313849b351ef7/httptools-0.9.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:06bfe7fad972a417269d8a5fc53b87e4eca970354abf5e9e24336fd06d64292e", size = 571731, upload-time = "2026-10-09T19:55:52.422Z" }, + { url = "https://files.pythonhosted.org/packages/b6/35/e67e9c9dd3da036ebfcbd273eec44bd39213f952d638858b09b9f3ecaf3f/httptools-0.9.0-cp314-cp314t-win32.whl", hash = "sha256:c42424213c28804f8d0e20f5692106cfb57bf72e1dbc4092b8481fb2f9e4c707", size = 94622, upload-time = "2026-10-09T19:55:53.982Z" }, + { url = "https://files.pythonhosted.org/packages/c5/5c/af620c73de59b5f3d431ae778c7412d30bba7bf56ca8b4140107a8ac0e54/httptools-0.9.0-cp314-cp314t-win_amd64.whl", hash = "sha256:bb1533541c729ad422f870a780d8b4af924f9817d45b5f580390418cda72eaa2", size = 101934, upload-time = "2026-10-09T19:55:55.417Z" }, + { url = "https://files.pythonhosted.org/packages/90/90/fc6019b5179d13007c6c3039346ea2696cf2e94369d6ca96e57f23b01989/httptools-0.9.0-cp314-cp314t-win_arm64.whl", hash = "sha256:6f9549ca354a1d6d6167c458a1f1b12147726b968f02dd64b6a5801dba91ae0f", size = 97211, upload-time = "2026-10-09T19:55:56.878Z" }, +] + +[[package]] +name = "httpx" +version = "0.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, +] + +[[package]] +name = "httpx2" +version = "2.13.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio", marker = "sys_platform != 'emscripten'" }, + { name = "httpcore2", marker = "sys_platform != 'emscripten'" }, + { name = "httpx2-jsfetch", marker = "sys_platform == 'emscripten'" }, + { name = "idna" }, + { name = "truststore", marker = "sys_platform != 'emscripten'" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d5/44/474bef2a0e9d90f1715d32cb98b0738695ca17ba324095fb2497ed7fbd59/httpx2-2.13.1.tar.gz", hash = "sha256:e48744a19e3af5ee48313d0ce5fe941d5422fae5705ea922a4aabf94d7800dfa", size = 100405, upload-time = "2026-09-23T07:47:23.052Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d8/9c/6fe8931fd9f381042a9e4c7d5a7b4cbf7016b252bec0c99a49fce42c3326/httpx2-2.13.1-py3-none-any.whl", hash = "sha256:6dff50fabc270ee5fd25d845d0b078ed20564579744d6d962850975996d2f9a4", size = 95597, upload-time = "2026-09-23T07:47:20.995Z" }, +] + +[[package]] +name = "httpx2-jsfetch" +version = "1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cd/c4/0e5636363151a2a1795e0a77617168b9ca438e1748ec05fc9b5687f93d64/httpx2_jsfetch-1.0.tar.gz", hash = "sha256:70a0e3eabfef7cce5ad9c629f7d01ca05e418f586646f4ddf14782e4c1454c60", size = 6872, upload-time = "2026-08-07T00:13:07.492Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9b/43/832f631d32e4f1211caa2ba368317739fe71f0b8530e4c9d15dc454bac2a/httpx2_jsfetch-1.0-py3-none-any.whl", hash = "sha256:cb916b707601e69a07721aabc8f3f6659be3a6893bc1ff5c6f9e02241df2da32", size = 6382, upload-time = "2026-08-07T00:13:06.567Z" }, +] + +[[package]] +name = "huggingface-hub" +version = "1.33.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "filelock" }, + { name = "fsspec" }, + { name = "hf-xet", marker = "platform_machine == 'AMD64' or platform_machine == 'aarch64' or platform_machine == 'amd64' or platform_machine == 'arm64' or platform_machine == 'x86_64'" }, + { name = "httpx" }, + { name = "packaging" }, + { name = "pyyaml" }, + { name = "tqdm" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/25/2a/484d112c0d8fc5f665d7b65137ac9cdb2953c982391598c3597968a12ee7/huggingface_hub-1.33.0.tar.gz", hash = "sha256:367be21a201db9523eddf8aeac7048f2602c1b308691c97640d5e72ed188007e", size = 1043548, upload-time = "2026-09-24T09:49:29.971Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fc/16/963096d224b80909432dc16561a615fd33d2d13beef3ce4c63fa25e40867/huggingface_hub-1.33.0-py3-none-any.whl", hash = "sha256:04e434b06e100eddbce9a6e817d72693a7884b10a79bd67ab48080d5c07eb899", size = 846435, upload-time = "2026-09-24T09:49:28.059Z" }, +] + +[[package]] +name = "idna" +version = "3.20" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f5/08/8eea9d4b8302028f3abb2c0813953f7aec26d33b7a8960ed760e65ff29fa/idna-3.20.tar.gz", hash = "sha256:a7db850025b95ded1eae8a46181a1a6c56c92c96f0e2b005d9ff8dc0210cab44", size = 216463, upload-time = "2026-09-17T14:11:04.752Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/58/a2/bb081bab032533a855d44de1d56f8e8426114ff1ba5d1f07a438a0a654f8/idna-3.20-py3-none-any.whl", hash = "sha256:ab7ae7122974553370f0bdb919e1a960b2cd1bc1ef0276416d896db81c14582c", size = 69583, upload-time = "2026-09-17T14:11:03.168Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/e1/2069291243c926a2ff1cd706c7f3eeb9b62144bf60f77c9fb9ff2fb26bd3/iniconfig-2.3.1.tar.gz", hash = "sha256:67f4b9c50da0dedf52af349e7749a80a9057a5031199791b906c3bb3ae878960", size = 21209, upload-time = "2026-10-06T22:48:38.076Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/56/43/4ca9e49d27a1fcf6bece6f6aec0ea46bb9112489b93d4b688fb415457bdb/iniconfig-2.3.1-py3-none-any.whl", hash = "sha256:9121e2c1fdb355232495be3194c8dfe87ccc2d5dee45947b78e68f499790d7a7", size = 7552, upload-time = "2026-10-06T22:48:36.959Z" }, +] + +[[package]] +name = "jinja2" +version = "3.1.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, +] + +[[package]] +name = "joblib" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cloudpickle" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d5/1d/537ab090f302b838943a1b56497dd53059b9a9b46a074936470173a2e207/joblib-1.6.0.tar.gz", hash = "sha256:2ccc96785b12046c08fd6d55839c12857831b54a3c1673ffadd2f04bfc4eda03", size = 327903, upload-time = "2026-08-31T09:39:04.122Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/53/84099323c2ec4be98d935f63c033ac4151ee83836ca1050ede3b3aadf155/joblib-1.6.0-py3-none-any.whl", hash = "sha256:3dbbf9f6e4b592a2357b854608e980fe6390d131d7a82f011a377ef2ebef7aba", size = 306115, upload-time = "2026-08-31T09:39:02.298Z" }, +] + +[[package]] +name = "jsonschema" +version = "4.26.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "jsonschema-specifications" }, + { name = "referencing" }, + { name = "rpds-py" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b3/fc/e067678238fa451312d4c62bf6e6cf5ec56375422aee02f9cb5f909b3047/jsonschema-4.26.0.tar.gz", hash = "sha256:0c26707e2efad8aa1bfc5b7ce170f3fccc2e4918ff85989ba9ffa9facb2be326", size = 366583, upload-time = "2026-01-07T13:41:07.246Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/69/90/f63fb5873511e014207a475e2bb4e8b2e570d655b00ac19a9a0ca0a385ee/jsonschema-4.26.0-py3-none-any.whl", hash = "sha256:d489f15263b8d200f8387e64b4c3a75f06629559fb73deb8fdfb525f2dab50ce", size = 90630, upload-time = "2026-01-07T13:41:05.306Z" }, +] + +[[package]] +name = "jsonschema-specifications" +version = "2025.9.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "referencing" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/19/74/a633ee74eb36c44aa6d1095e7cc5569bebf04342ee146178e2d36600708b/jsonschema_specifications-2025.9.1.tar.gz", hash = "sha256:b540987f239e745613c7a9176f3edb72b832a4ac465cf02712288397832b5e8d", size = 32855, upload-time = "2025-09-08T01:34:59.186Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/41/45/1a4ed80516f02155c51f51e8cedb3c1902296743db0bbc66608a0db2814f/jsonschema_specifications-2025.9.1-py3-none-any.whl", hash = "sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe", size = 18437, upload-time = "2025-09-08T01:34:57.871Z" }, +] + +[[package]] +name = "librt" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/04/f5/9dc696772d241814bacac7880bac32f2930b5a6ebc1f85317b83161a011c/librt-0.16.0.tar.gz", hash = "sha256:ac38d6d8d66bf3d744148dbbc0b8e193e195a51e364ed55e224631f5721891fc", size = 219839, upload-time = "2026-09-29T00:55:32.891Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ad/76/bbdaeb87b7c47b5c7343e90222b9bfa4e4a8a83f647e02933ad0129225b1/librt-0.16.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:fe52bf4641069e7978a14253b036cb9002def1926317e710f2e249f8a8c47742", size = 151028, upload-time = "2026-09-29T00:52:41.508Z" }, + { url = "https://files.pythonhosted.org/packages/fd/0c/ab8ed3dab0085931aec4a792c7eaac8dc6c5ff4691fda3a5360d9d8a9cd2/librt-0.16.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:5bcc2c4726ced915b00de0c9856a4eeabfb3fddb93e10e0b8f735b7709358b6d", size = 155283, upload-time = "2026-09-29T00:52:42.873Z" }, + { url = "https://files.pythonhosted.org/packages/eb/36/494e79d460c80c1f030661e8287c9eca5e1ad652dc2b2180b6cd42abce0a/librt-0.16.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff7baa55f8e7c69851419e50a666015d02a74198716fd45c0125a2112e0a389f", size = 503185, upload-time = "2026-09-29T00:52:44.638Z" }, + { url = "https://files.pythonhosted.org/packages/9b/34/a8464038dd9db6e4381fa2b6eb73dc9a50888d77102c4c139304ac35cddc/librt-0.16.0-cp312-cp312-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:b95d5d92ab83d39e760a52091bb1baba664f3a2351e39b1e16801e5747c2f0e9", size = 496738, upload-time = "2026-09-29T00:52:46.441Z" }, + { url = "https://files.pythonhosted.org/packages/6a/53/e0e5e334ef0c6ed27039d323819368b9ef6712be87d55ee2bf9799398afd/librt-0.16.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b6d085d70bce51d43c5c7c36d63490770180d8779e71c49305c87b4213918de7", size = 513818, upload-time = "2026-09-29T00:52:48.068Z" }, + { url = "https://files.pythonhosted.org/packages/ad/f7/7ce72cbf19d0addd05090b339152fd0548a02562c2866a603e6e3b3da2df/librt-0.16.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:36e53948e99bbe3ffea257124cfcae1cfb01831555c9a9c903c9f9a72db7fd07", size = 531952, upload-time = "2026-09-29T00:52:49.816Z" }, + { url = "https://files.pythonhosted.org/packages/83/22/0b1bcb6a8e723c8b4fd60dfc8ae8ec6461c54073fbc8685efeb8d900d407/librt-0.16.0-cp312-cp312-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:54d11f726aae9df5a6ffbbf0a03a52449bbac84a53ef03669cb41cdfd4ae41bf", size = 524465, upload-time = "2026-09-29T00:52:51.354Z" }, + { url = "https://files.pythonhosted.org/packages/64/2e/e9c23b8b9df1813da1be205deca9606beb7ddd033972246cd426d05374a0/librt-0.16.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:4323193ac0cd025f85af531df8ba91bf24d1973b401697347a6282e8fd3fcf5e", size = 543191, upload-time = "2026-09-29T00:52:53.284Z" }, + { url = "https://files.pythonhosted.org/packages/bc/e5/6a8b21b342c03ed7e230fa3afbfd2edc58e6e87ef1f0d11fa2b9a748c252/librt-0.16.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:e42f8e098b9c5396fefa05fb1cc7e33b0e08fc51da106b5de4a45fd22aac6743", size = 546979, upload-time = "2026-09-29T00:52:54.932Z" }, + { url = "https://files.pythonhosted.org/packages/84/9e/b5129023eced1be01e01c22757f53be551d463b1bb7264f787927404c1d6/librt-0.16.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:39ec1d5a14e37baf1450a6cabf03fe552340808bf1ad9d71824ab90117716459", size = 555317, upload-time = "2026-09-29T00:52:56.869Z" }, + { url = "https://files.pythonhosted.org/packages/71/89/28bba5938c725fe91f06bf93f7fa6c6b150229df53a87b454b0d5c2a796e/librt-0.16.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:d1aabe3925cbb4a08d15b7b20ba4011b53019da0c4173a25155139b7b1baed65", size = 535846, upload-time = "2026-09-29T00:52:58.475Z" }, + { url = "https://files.pythonhosted.org/packages/22/92/63773026614f888c5d4e370e395ce42ca604b89f70b3acdfedbf94851b80/librt-0.16.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:300c3ffdc459f4a779a8411ecb188e3ac0b1ff3a3a7b099642555dedae06c69b", size = 573700, upload-time = "2026-09-29T00:53:00.127Z" }, + { url = "https://files.pythonhosted.org/packages/66/8f/347d4677eefb57cd9f8e01d95a1dcee9a91b4e44e664173c32ff6f3752e5/librt-0.16.0-cp312-cp312-win32.whl", hash = "sha256:c17194318e4c0c0348b36f36c2ec7534436fe0a4c15582403162a4f08c80797a", size = 106158, upload-time = "2026-09-29T00:53:02.031Z" }, + { url = "https://files.pythonhosted.org/packages/f0/2c/5193dc81127cd5ddfad031391b046bf32dda219b38463ab872407ca30646/librt-0.16.0-cp312-cp312-win_amd64.whl", hash = "sha256:25a58a19ea8d83b68209f04912df765e9260635ef77646542ed4b4abe6bc7940", size = 126176, upload-time = "2026-09-29T00:53:03.445Z" }, + { url = "https://files.pythonhosted.org/packages/ff/3d/9668a400c8dd81d162eba38b33fa49fa6205f1a64493570a13fbb815c3ee/librt-0.16.0-cp312-cp312-win_arm64.whl", hash = "sha256:f7be7cf555bc30ec12622e9447299cc4a9b8ff307548b634794353db0c2065dc", size = 116572, upload-time = "2026-09-29T00:53:04.815Z" }, + { url = "https://files.pythonhosted.org/packages/46/cd/ae5e0e9dba45d1399aa04a5395bcc0bead40d9fa06dc903634a7b4d7473d/librt-0.16.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c5e6144e68b577f157519f2ba88ca20e3ed61c29b00e5cdfa76cd2d45acf059a", size = 151036, upload-time = "2026-09-29T00:53:06.284Z" }, + { url = "https://files.pythonhosted.org/packages/41/5a/48a16e323c5f9447a94cce7b59babf60fa04e62c3365ecf060c77ed8b320/librt-0.16.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:33f41443a1f4e1f099331b3d8120e409fbff84b9760bc1cc9ea496f37ddaa5cc", size = 155211, upload-time = "2026-09-29T00:53:07.72Z" }, + { url = "https://files.pythonhosted.org/packages/3f/29/0f59299eb4251a409b2e690ad4b7d9f8a676db829d7817ec961f32b44f7e/librt-0.16.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7e510b7770bee609617a3374a96548eb114cae048023e3f049ee449e7ff2db32", size = 502587, upload-time = "2026-09-29T00:53:09.191Z" }, + { url = "https://files.pythonhosted.org/packages/de/ba/d6fb4ef8d1537c396079d72289f16be7cd35a366e5065c51253fea2760b6/librt-0.16.0-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:efc49c462d4516b8a58b00b490078fa64689fd1fe66970cc190131d7afb8027e", size = 496146, upload-time = "2026-09-29T00:53:11.016Z" }, + { url = "https://files.pythonhosted.org/packages/52/fc/8c50dd4d7cc97c0ee8f252c8a3104980f234391cf1519b554e8b9de08b60/librt-0.16.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:92caf82ebef5e12d21c72242b70d1e92536f1711cf2a727a4c276de4b4469087", size = 513336, upload-time = "2026-09-29T00:53:12.655Z" }, + { url = "https://files.pythonhosted.org/packages/a9/59/16c409c56f708eda2db9a0553662845d45e3871c77d70a240dae3f3bdc56/librt-0.16.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:17bac7f7a16b328fff77e440287693eb017abde913595b5827ebccbc21ecd8a6", size = 531689, upload-time = "2026-09-29T00:53:14.39Z" }, + { url = "https://files.pythonhosted.org/packages/88/82/d34772a6c29d1446dcca6e64d74062efd508523ab351aa16625a4689d5cc/librt-0.16.0-cp313-cp313-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5b976054553670829985ed767feb78fb6bcede0175327c4844dd5c281c1be659", size = 524537, upload-time = "2026-09-29T00:53:16.064Z" }, + { url = "https://files.pythonhosted.org/packages/77/8f/24c5631313746131ccee53bc91fdc8374f9cf25e0082a1fee9c93bb98acc/librt-0.16.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:0058f9d68721094105917254c72ac0569117bb7b13b9769cf45d26d89f9d21cd", size = 543231, upload-time = "2026-09-29T00:53:17.939Z" }, + { url = "https://files.pythonhosted.org/packages/f2/cb/5f8e0d41dbd8b499c2265e939c31acc9ba59845565bf99539ad1c06aebcf/librt-0.16.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:30b7beaf3f4487b7d8adef1f158b49067cb4d5a19fa7a3bf31a4e7a820e435c5", size = 546499, upload-time = "2026-09-29T00:53:19.666Z" }, + { url = "https://files.pythonhosted.org/packages/be/61/063052de441d1385f59cea4223f184bf9e5d125de1ae3239b490aa1e486e/librt-0.16.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:468df902df016a06eb0e40b0747dc8d14e47d7a38b18b63b1fb167d85cb94d63", size = 555261, upload-time = "2026-09-29T00:53:21.292Z" }, + { url = "https://files.pythonhosted.org/packages/14/11/a2ada0529372268d6401afa9d457a095b68cd7753532b6f7f33049a19b43/librt-0.16.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:aea7b1f2b125dad5de85f049136651bff256c883c65e6b9209b2da0a1ac3cdef", size = 536029, upload-time = "2026-09-29T00:53:23.07Z" }, + { url = "https://files.pythonhosted.org/packages/23/9d/5bb6d38853382986dca702fc7e06c8256d30f5fa0676d882773b744610dc/librt-0.16.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a8afb6557920860b7a3a596eb804cf37e09e7cf8a803db2478c202acc72d8c2e", size = 573748, upload-time = "2026-09-29T00:53:24.696Z" }, + { url = "https://files.pythonhosted.org/packages/99/f6/0025cde35ff7f607684dc775a2b2d732cce2561cec050a6ee1fb2e1fc6fe/librt-0.16.0-cp313-cp313-pyemscripten_2025_0_wasm32.whl", hash = "sha256:77c7a2b4fe2c1369e0d5aa1cade26740a7b14be32fbc9a5535d617d20065c39d", size = 79861, upload-time = "2026-09-29T00:53:26.089Z" }, + { url = "https://files.pythonhosted.org/packages/93/93/303b8592909bd583f83f02818ffbea3f7647ca1e22b1cd5465d04b8145fe/librt-0.16.0-cp313-cp313-win32.whl", hash = "sha256:02d89c813d5ff74b17df72d3a34819d132cd168e56b81bf755b809bd9e46b8c4", size = 106236, upload-time = "2026-09-29T00:53:27.43Z" }, + { url = "https://files.pythonhosted.org/packages/cf/24/80bbb463c60ed18e29cb26aba386ddb76609580e4e7160710e550587018c/librt-0.16.0-cp313-cp313-win_amd64.whl", hash = "sha256:14ed6ebe3e4f85f326d7920011ad30ff49ed9334e62cf88caef9ba973d9e3a92", size = 126180, upload-time = "2026-09-29T00:53:28.7Z" }, + { url = "https://files.pythonhosted.org/packages/43/80/b1a6fbdd7da825cdd55c71aa81eb6cfa82c513c360774152eacb50b4a771/librt-0.16.0-cp313-cp313-win_arm64.whl", hash = "sha256:83d4041a3d9b2fd053a8a4e1f22878b3e5833e2712956382d5c048d791454e91", size = 116557, upload-time = "2026-09-29T00:53:30.012Z" }, + { url = "https://files.pythonhosted.org/packages/1e/93/9e0cf7da129a93c3dc7f45bc3cd4a660f2aaa995aa8a6f95c2583ef41239/librt-0.16.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:931a0bb0fcac88f263e269e46eb30ba8e21402cd3c62ca40cb97034c0693fab1", size = 149844, upload-time = "2026-09-29T00:53:31.391Z" }, + { url = "https://files.pythonhosted.org/packages/8f/26/8a90d2a8f2b2e471bb486b7aec117b8ae622715ed6c39853aec48ea20073/librt-0.16.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1bc17e54e5305f8d40b7ca203671ff5a9e59c1d0f8ea0f625dcca53a3984de11", size = 154111, upload-time = "2026-09-29T00:53:32.718Z" }, + { url = "https://files.pythonhosted.org/packages/35/ce/67abb46258da4d3e42ff5b141db6f38c59357bef84c7979f183b22f924f1/librt-0.16.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:877698bf6bca5721d8be345f2fe09778e40ecadea8b58c73075f2b1a53666bf2", size = 494234, upload-time = "2026-09-29T00:53:34.466Z" }, + { url = "https://files.pythonhosted.org/packages/12/f9/ea7162414a16f8f1bbd3b493ad6d22b926c5471916e078350bdbca8c4e5f/librt-0.16.0-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:5981c011b306781ce561e18e14230a14524a3d8109b97553666c942c18f31a96", size = 491173, upload-time = "2026-09-29T00:53:36.124Z" }, + { url = "https://files.pythonhosted.org/packages/b1/09/9b3e869060dd33f9989b80ba4fea306f6db8cecefcdfe6d7346ef0603f65/librt-0.16.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:afced3dfc17cd805ecf7a3d77996a71cf5f2c75aa66eb0c21a9930f4fc992f86", size = 505547, upload-time = "2026-09-29T00:53:37.686Z" }, + { url = "https://files.pythonhosted.org/packages/50/07/79007d2165f649ea93e08c0962d1d62c70af9cde77965255095bf9d96f9a/librt-0.16.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ca8052401c55d7511dda6760719fda7618067e83535d7d0010096d216c34b667", size = 523057, upload-time = "2026-09-29T00:53:39.301Z" }, + { url = "https://files.pythonhosted.org/packages/61/0c/8fbaff66d0ba376d8864653f5acce1569bae27e89648671f26f7eca67ab8/librt-0.16.0-cp314-cp314-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1e511762a074005bb0aa569166779834e75e438370226930d0ce1866d4b6a33b", size = 515158, upload-time = "2026-09-29T00:53:41.012Z" }, + { url = "https://files.pythonhosted.org/packages/df/2e/23ff0dece76f07a4124413a57682efa0bbeb5765ac0122bc0955513f82ca/librt-0.16.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f1e8591bd8a5a628cd7f07954c6a1592359a878bf032957a8e9057a41d644311", size = 534183, upload-time = "2026-09-29T00:53:42.451Z" }, + { url = "https://files.pythonhosted.org/packages/26/c4/e11dea21d9a29486eba78887380374189d472734fa32cc50cb37ca44d3d0/librt-0.16.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:a4aaefb4ba6c07e1aeebb2795c8958148f1d6f9af3b555b53d23d766edb6d67a", size = 540663, upload-time = "2026-09-29T00:53:44.272Z" }, + { url = "https://files.pythonhosted.org/packages/44/75/e873ae158a8b7f5359be33e7fd6c1fbe02d9a78a3e89a77de6b0e837f476/librt-0.16.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:d92db7a0f6aee44f1baee94750457e8d2d1c6ccea41842de6268d34e8dc7eddd", size = 545908, upload-time = "2026-09-29T00:53:45.812Z" }, + { url = "https://files.pythonhosted.org/packages/ba/36/8939d3f6a93e11bd9592e6fe28d2b44f1c2dc4bed6e22e72359a91e18ffb/librt-0.16.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:378dfaffb38e59c24a87cde5713cd865d51ff7383fa12947f3907f306ea1ca55", size = 523302, upload-time = "2026-09-29T00:53:47.596Z" }, + { url = "https://files.pythonhosted.org/packages/71/14/35309f44a077f0f42ade0e2e7cd88c0cea760c661af205c01ea90d3c0e1f/librt-0.16.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:3e0c39bdc85370422e8b637be76eb1fd07d30967551b03e62267dd156f553152", size = 565911, upload-time = "2026-09-29T00:53:49.278Z" }, + { url = "https://files.pythonhosted.org/packages/78/0c/df6255b94967f3159ebc46f08d8e783c12da6ee269ddb74b2efed63c640c/librt-0.16.0-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:1b384b90ab79a7bc30b566895809a636e0666f21f3cf12b54823d025b7e83839", size = 79119, upload-time = "2026-09-29T00:53:50.938Z" }, + { url = "https://files.pythonhosted.org/packages/6b/44/d30d5a5461378c9d33f36736c6791c3b4c4ba4b1ffe0da7350aedcb2c9a0/librt-0.16.0-cp314-cp314-win32.whl", hash = "sha256:52327da75a94012e7f932f913d20d3876bed3c102be00e6c3e8600ff7bdd58a7", size = 100140, upload-time = "2026-09-29T00:53:52.205Z" }, + { url = "https://files.pythonhosted.org/packages/c2/98/769712f356a1e897df3581bb0c3100375d054a000de26099360ea65b5111/librt-0.16.0-cp314-cp314-win_amd64.whl", hash = "sha256:3f0b8114c44b2ac06ff5dacd08e07e8e807ff4f46083f2a1602685122559be41", size = 120464, upload-time = "2026-09-29T00:53:53.495Z" }, + { url = "https://files.pythonhosted.org/packages/bf/d3/ae2abccc8bdc8b063c1613a77686e17b74e9d3d60cfe6f12c63fa2821b9f/librt-0.16.0-cp314-cp314-win_arm64.whl", hash = "sha256:8caf96a4ef8fb27d0ac0d1ad8337d26a240acd4a02fe4345d0a8f264753e8f99", size = 110889, upload-time = "2026-09-29T00:53:54.817Z" }, + { url = "https://files.pythonhosted.org/packages/e4/26/0737d4be058dd6376eade7dd8b380d4b869b6394cee929393a5a431c45bb/librt-0.16.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:953107e2f68d0f3512c48f898b0dbf0ce5cc52bba0f318d847c985dc555ee4cc", size = 159838, upload-time = "2026-09-29T00:53:56.22Z" }, + { url = "https://files.pythonhosted.org/packages/01/96/9bc96531d7c620e9949af904470f02e3fa8f35129ab8e8f281c51eaa3788/librt-0.16.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ad37d5b9abd49c9a655dcda7ea52a8a752884062ef1ee71ae17c2f2a0f81fe6a", size = 162018, upload-time = "2026-09-29T00:53:57.532Z" }, + { url = "https://files.pythonhosted.org/packages/0e/fa/b0289dcb186eb3f97221ba00da5f4bd3ba7fa5e99752d48aa8d336615334/librt-0.16.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2c4aa329c17bd1aaea4f6e89335d8ccd494b3a5830b6654462273e50e11023f0", size = 703983, upload-time = "2026-09-29T00:53:59.024Z" }, + { url = "https://files.pythonhosted.org/packages/d9/ab/05ebbbde7530fc5eeb58fbd1581522a9f64f132fa703c4c595eed6e14760/librt-0.16.0-cp314-cp314t-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:0ead24d2562a49473dddd9efef8581f020007eb0054389c3ee3ffad38b1ca4c9", size = 683847, upload-time = "2026-09-29T00:54:00.671Z" }, + { url = "https://files.pythonhosted.org/packages/a3/75/f52aeecd4dbadbddf80725ba7de126d8bd5d0eb66247eae17a81ed90dd4d/librt-0.16.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:02118f56a9c36ddd07dfd9b919d9ecc117ba20a90987d56aa4c429fa34509188", size = 698045, upload-time = "2026-09-29T00:54:02.247Z" }, + { url = "https://files.pythonhosted.org/packages/e6/55/fa277a835cd6eb42380591ceb85f48c5b4d2b2d7e2cb9869e1e17d24d237/librt-0.16.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4e29522c62e28595ff7e324c6834ade51127707f0e255b18d1c1cf03d39c1048", size = 724640, upload-time = "2026-09-29T00:54:04.078Z" }, + { url = "https://files.pythonhosted.org/packages/25/e4/2cf64354f3fde8ebd591b3b48f96510bee24ac5f7d1e567adbbe210abdbd/librt-0.16.0-cp314-cp314t-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3ff4b2367926b69c6215635902cccb04048e73094e9862900d27cb2c6bbff143", size = 731748, upload-time = "2026-09-29T00:54:05.741Z" }, + { url = "https://files.pythonhosted.org/packages/f8/c9/c180af3e94e01aa529fa93d7733fec2abc47f222345400cf21d5481d5f8a/librt-0.16.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c6f1b27bf1632a7e016af9f145f82be95e1edd7721a646505c21059257cb5a04", size = 754960, upload-time = "2026-09-29T00:54:07.38Z" }, + { url = "https://files.pythonhosted.org/packages/95/d6/01073aa78c58f356b10d9c57b3fe9abb143df338142bcd316df417b89db0/librt-0.16.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:5696d7f52e7b37217cb3a8f92c744fe835942602fdd4c1a8bc4741d3bfdce15e", size = 747000, upload-time = "2026-09-29T00:54:09.06Z" }, + { url = "https://files.pythonhosted.org/packages/f7/d8/1de3783908658d697a8cfc00582f61299ffba7796d7260c112e4700b1109/librt-0.16.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:6072e92dd876ff6ceeb6cf371e35e51f479349837391341f479b08df4564242b", size = 749569, upload-time = "2026-09-29T00:54:10.702Z" }, + { url = "https://files.pythonhosted.org/packages/b7/32/e817f66c96d6caa8bb8435ff93c4c220624d59efc506be59ab98fcd01d0c/librt-0.16.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:39ca4f2f2fe05de8e63493da592d84311adabe5bef52b193851981da9816b302", size = 729483, upload-time = "2026-09-29T00:54:12.25Z" }, + { url = "https://files.pythonhosted.org/packages/7c/c9/23992ccd2b9d22798fdd0f61353183a47414e651da782ad83eb680f50833/librt-0.16.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f9807485a908f00355820f18e91e045ffdcdc5adb68aaec40a1e2b88c5f7bba1", size = 776584, upload-time = "2026-09-29T00:54:13.837Z" }, + { url = "https://files.pythonhosted.org/packages/67/3b/e8af957f08e6e2e8e566b099e43d2748418aa565df6ee1d9d3331209fdfe/librt-0.16.0-cp314-cp314t-win32.whl", hash = "sha256:94be5cb7bca4df6201f4183e9e4fa2086c655283d20b38cd84500a69057575a7", size = 104402, upload-time = "2026-09-29T00:54:15.568Z" }, + { url = "https://files.pythonhosted.org/packages/ca/1c/946e6443d7cd32347a086043395e421e52fd603c9163d2ec970ceab8eed6/librt-0.16.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d46ca272b251d033dd4527b0dec5f261a28a52bd5fa0f99c117b0a1f8588cc2d", size = 126227, upload-time = "2026-09-29T00:54:17.165Z" }, + { url = "https://files.pythonhosted.org/packages/c2/a3/bc4f9959d3c62bcbf9e5fd3470a8bbd8bf33224ed4c25d7fba173201b8ac/librt-0.16.0-cp314-cp314t-win_arm64.whl", hash = "sha256:b9d6d4b14e92d876f8026b54c20c445f36425214c1081dc76f74e40db386b82b", size = 116493, upload-time = "2026-09-29T00:54:18.567Z" }, +] + +[[package]] +name = "markdown-it-py" +version = "4.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mdurl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/ff/7841249c247aa650a76b9ee4bbaeae59370dc8bfd2f6c01f3630c35eb134/markdown_it_py-4.2.0.tar.gz", hash = "sha256:04a21681d6fbb623de53f6f364d352309d4094dd4194040a10fd51833e418d49", size = 82454, upload-time = "2026-05-07T12:08:28.36Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/81/4da04ced5a082363ecfa159c010d200ecbd959ae410c10c0264a38cac0f5/markdown_it_py-4.2.0-py3-none-any.whl", hash = "sha256:9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a", size = 91687, upload-time = "2026-05-07T12:08:27.182Z" }, +] + +[[package]] +name = "markupsafe" +version = "3.0.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/38/9b/e422a865e1d5d57d0e509b4e0bf1c1a70a7f6382c29a5aa428df994c8bc8/markupsafe-3.0.4.tar.gz", hash = "sha256:2e9ad7dd851bf45fab9f75cbff4cb493fee9979e8d8c7c9c3ee119022518edd6", size = 153777, upload-time = "2026-10-02T23:07:22.29Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/81/09/4c59d56b8461ae8eb0d8ba34bb25b7e618547044679d58a82ef9b2479fc1/markupsafe-3.0.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:61631e08084be9e21a8967ec3139c7616ed7c5e9368e05c86d1b39562c8a57b6", size = 11658, upload-time = "2026-10-02T23:04:51.876Z" }, + { url = "https://files.pythonhosted.org/packages/a2/f0/d6613774d86fbf6d145751d43c59875e47a6f9f17daee0aef173bd36d90e/markupsafe-3.0.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0930db9bdc62d22944e10b066448bb65dc9abe9112880c7cab8da54db4284d5f", size = 12050, upload-time = "2026-10-02T23:04:52.931Z" }, + { url = "https://files.pythonhosted.org/packages/0d/f2/8f18e0b806eb13c1f8d07d917a720831ead54253a6dec011fbc78098a6f8/markupsafe-3.0.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6a45c3d514f2436064db00d7fc8778d888f0236ebfed649b53d13a59e69ad51b", size = 24363, upload-time = "2026-10-02T23:04:53.895Z" }, + { url = "https://files.pythonhosted.org/packages/60/ce/fa07dbe8a5675558fa36dea033e19995bc783de2dec5f540ccb9030b06aa/markupsafe-3.0.4-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:1e1451fab512d1bcc3dc26988ec1edb0b82c2db909132872cd9356070a6b63df", size = 28525, upload-time = "2026-10-02T23:04:54.905Z" }, + { url = "https://files.pythonhosted.org/packages/85/40/be87c01f3868ec217f8a2015089d71c22c8c5a75324822e5ed1cdd87210d/markupsafe-3.0.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:bd3ce56ae2cbae3ba82b683bc425cd7e48d2ed8b10f3e818186b6f5646d9271c", size = 24733, upload-time = "2026-10-02T23:04:56.229Z" }, + { url = "https://files.pythonhosted.org/packages/4f/a7/aeedb5140afa41fc74c225e9184ab96723a6e873b6ee1c9fede7283456d8/markupsafe-3.0.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8e124f974786f831d6043728e38296969d3579db8896fe004682f5758e613581", size = 22985, upload-time = "2026-10-02T23:04:57.521Z" }, + { url = "https://files.pythonhosted.org/packages/c3/fc/e91352bb08c6a59da3ef0909d457bf95a5f5908fbf151b30a06d9dbcfbb4/markupsafe-3.0.4-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c02e8f18bdedba082cef725942ac823b9b60656db07f7e265cb31618dfd00d77", size = 22001, upload-time = "2026-10-02T23:04:58.597Z" }, + { url = "https://files.pythonhosted.org/packages/5d/f8/bffee5e7d2a3deb59748a797650a48af7e672025cf641a79344a771ad106/markupsafe-3.0.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:9f098115c247e11d138ab83a28fa0323c77015007ea2df73ba5fd714dfefd67c", size = 23793, upload-time = "2026-10-02T23:04:59.686Z" }, + { url = "https://files.pythonhosted.org/packages/ed/59/b853d6628ecb4d658e1d637224846d5e9bb4adf4f8df97f3be9f29dce2ec/markupsafe-3.0.4-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:d5f93ebbeb8032d47e349328ec8662d973d9b05a70b3c35df1f91fe419b84749", size = 22640, upload-time = "2026-10-02T23:05:00.768Z" }, + { url = "https://files.pythonhosted.org/packages/09/b2/1506df394f0f075797c418d0301498f49e43be194e3ffcb49e6fe6ccf022/markupsafe-3.0.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:64511c54db4e4987aef4c41923235927428729e8174c5dba488429be70a998ed", size = 24074, upload-time = "2026-10-02T23:05:01.813Z" }, + { url = "https://files.pythonhosted.org/packages/c7/81/5ed69cda630ac69ef60d06c09ba5a7f84ff66a2e28cf986fd5614ab3c6e6/markupsafe-3.0.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:e1a622f13970d81f95d0c72f9dc090dce9085fccfa4c9f2174377ee32bd15786", size = 21563, upload-time = "2026-10-02T23:05:03.239Z" }, + { url = "https://files.pythonhosted.org/packages/0c/fe/fb1e79be0fea60aa32602ebefc9c35a82bb42b4df157285ab7dfec12341a/markupsafe-3.0.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c9a7f43c0b202b334cc9184af09bb8f21d3a209e038efaf106936fb69e6b026e", size = 23048, upload-time = "2026-10-02T23:05:04.479Z" }, + { url = "https://files.pythonhosted.org/packages/c8/52/7632a53360671a9b750cdbabaf9cdd89f18b42248b8e4cb42c0b0296e459/markupsafe-3.0.4-cp312-cp312-win32.whl", hash = "sha256:f0ec3b750b59375eab5b0fb2b9254810c00a3375be6d789899f1055a1d556237", size = 14108, upload-time = "2026-10-02T23:05:05.513Z" }, + { url = "https://files.pythonhosted.org/packages/3f/bf/62495e180b7000aaf30000fff849e933f74264638057176cf46852500adc/markupsafe-3.0.4-cp312-cp312-win_amd64.whl", hash = "sha256:11935df9bf455ed0c04eb87bcd720f02b1fe5e02128a9430f23aed6f93336fc7", size = 14303, upload-time = "2026-10-02T23:05:06.538Z" }, + { url = "https://files.pythonhosted.org/packages/c5/8e/4c24208776a65878d656996945aacfbfe010d3720d1a98fc0eb8491fc03b/markupsafe-3.0.4-cp312-cp312-win_arm64.whl", hash = "sha256:a4bbd2d87dd233b9fc5812160c3d0ffbe42edc22a26ce0469f58479ede633fe9", size = 14174, upload-time = "2026-10-02T23:05:07.617Z" }, + { url = "https://files.pythonhosted.org/packages/6d/18/4bc5ba32499e87bb2b0ef5b3a9bb9c00a131fa961ddf0be548cb550f548b/markupsafe-3.0.4-cp313-cp313-android_24_arm64_v8a.whl", hash = "sha256:de8b364c423ef0a4bad9069657d617f9a5d2b2062457a89b1fa16ee199c399c1", size = 12786, upload-time = "2026-10-02T23:05:08.709Z" }, + { url = "https://files.pythonhosted.org/packages/4e/6f/17f0c099bf25f3e31e63cc19244d9f6af861a9a4ab778c203997903cfdd0/markupsafe-3.0.4-cp313-cp313-android_24_x86_64.whl", hash = "sha256:34bdde374c5932765d7dc685c4a1d191a3207852d67e8e0a9eb6ea85156181f1", size = 12510, upload-time = "2026-10-02T23:05:09.93Z" }, + { url = "https://files.pythonhosted.org/packages/11/af/1a141081b905036ee904ec4bd945e1f70b4e1b32d33c4e59e8cf1d58b247/markupsafe-3.0.4-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:6bd9e1788e15bfcf6a9082de42e30387e7b85d211ab21e57a939bb8cfaaf8d96", size = 11583, upload-time = "2026-10-02T23:05:10.884Z" }, + { url = "https://files.pythonhosted.org/packages/e7/0a/a89385ae590232622a03e091805cff12f24fabe6c11e0e8bae096cece81c/markupsafe-3.0.4-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:5066b244f576f91afc8ee3ba029a89f99d39c79b1853fe9d39bea9f0afbec148", size = 11801, upload-time = "2026-10-02T23:05:11.913Z" }, + { url = "https://files.pythonhosted.org/packages/ed/85/ea548dc013962eb73653124bc595635fbf9e0fa41d1f181a967ccb784dfb/markupsafe-3.0.4-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:7a83aa6e4805df46fed18e989d3d16f86ef60cb50bbc8d9ce3a6be89165fbf6e", size = 11443, upload-time = "2026-10-02T23:05:12.887Z" }, + { url = "https://files.pythonhosted.org/packages/cc/72/15f2e5ec9cf2eb00d5cdfe968d94e4156a7bd7303832c3f3b2c403a36839/markupsafe-3.0.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:2d1b7d9308288661f56672b1b157d75fc536714d3638487bbea17b6318a78248", size = 11665, upload-time = "2026-10-02T23:05:13.829Z" }, + { url = "https://files.pythonhosted.org/packages/ca/e0/4030bea613677e333c8a2c901fd405055f657f9d06acba5b7357984b6ef7/markupsafe-3.0.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:73e77980c7207854f00fc4e71fb1626868d5740ab4012623d55c7a99ad122a72", size = 12061, upload-time = "2026-10-02T23:05:14.807Z" }, + { url = "https://files.pythonhosted.org/packages/f3/a5/28b76a7449eb702966b88bef599e2360b411fbb3afeee8fe560939be06ec/markupsafe-3.0.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7018d4af1cd272e847aa5917983ab5e83e4f6579f9dbfecd4a79c0ca80b144c2", size = 24395, upload-time = "2026-10-02T23:05:15.909Z" }, + { url = "https://files.pythonhosted.org/packages/07/6c/21232811afc3a063b5e934b1ae2efda52f46154ec382f585149c020e61fe/markupsafe-3.0.4-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c90d5b3d4e944e065a301d741b3c1d784f6bd1f503aa68b4967e32b2ba313d85", size = 27183, upload-time = "2026-10-02T23:05:16.976Z" }, + { url = "https://files.pythonhosted.org/packages/14/38/6ccdfa5b59049cb36fb80cbc80aee9cf1fc9bb77d1335ad435f2070b08cf/markupsafe-3.0.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:18a801868a884f216e784d7d14db2a4077143ce7610440aee2ce8f734e7cfcde", size = 24772, upload-time = "2026-10-02T23:05:18.209Z" }, + { url = "https://files.pythonhosted.org/packages/63/e0/cec6865dfe88cb48fedd4b20aed6af5158e41092adcbf3e028bcc6ec2108/markupsafe-3.0.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:434139499bb20b502ed3baa1f169e618f924a97e7a777fea1a49446d80106cf6", size = 23017, upload-time = "2026-10-02T23:05:19.286Z" }, + { url = "https://files.pythonhosted.org/packages/ee/76/6ed4940bb7648a9aac457c14f870cfdd5105f139a0fb1f29cd61fafa47d1/markupsafe-3.0.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9e227f3dbe6bde7491cf0a9965d00b88c6b1a4a95d11480ddf88bb96d397c19f", size = 22036, upload-time = "2026-10-02T23:05:20.352Z" }, + { url = "https://files.pythonhosted.org/packages/a1/4f/ed476226d4fe46a09090a36025bf319296810028df55eb12f1253b540f3a/markupsafe-3.0.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:b8cd1f918b26fd7b1832ece557cc18f2d8747309ff8b3f0ef9d4250c5ad67a39", size = 23786, upload-time = "2026-10-02T23:05:21.576Z" }, + { url = "https://files.pythonhosted.org/packages/9a/35/66ff30450e35ef5fba9ebc930c9411747e537fd9447b65e44f5007e2b84d/markupsafe-3.0.4-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:a5fcffb37e602b0b3c1638a97746b9b96125caa9bcf6fa41d337a9261de231ee", size = 22672, upload-time = "2026-10-02T23:05:22.922Z" }, + { url = "https://files.pythonhosted.org/packages/32/0b/72f45ce4b4efcbca4b80cf1b06703eff0be8d37e82abb78f66c85a7ead1e/markupsafe-3.0.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:5989cb26b2e1efc6a42216a9f6b5ee495ce5ace2e5b352a9af489976b32d1ee2", size = 24107, upload-time = "2026-10-02T23:05:24.175Z" }, + { url = "https://files.pythonhosted.org/packages/d2/03/71776e5fdcba04614b384cc102e8a4198208579d896fd1394cb7cb9aa900/markupsafe-3.0.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:add96447a86d205ab616665d53b2950ee81083757f56e6ea833c8b2917646b46", size = 21615, upload-time = "2026-10-02T23:05:25.215Z" }, + { url = "https://files.pythonhosted.org/packages/ab/5f/801ce02a02e7aee0f784b1ec7843026178f6adeb9c93ac67eb1992a9a84d/markupsafe-3.0.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:2628d3a8cb648ecebb3c5d6b0a1052d400e4d8b7ac0fb786be8d285b50040d17", size = 23076, upload-time = "2026-10-02T23:05:26.423Z" }, + { url = "https://files.pythonhosted.org/packages/4a/85/c43776625428f3bb4a61e8633940400e3efe6409e3c6f5bff26de5e45618/markupsafe-3.0.4-cp313-cp313-win32.whl", hash = "sha256:672d207103e6b16ca098611b0f9efad6bc00afd47c03d6ef62186495ca677dc0", size = 14118, upload-time = "2026-10-02T23:05:27.716Z" }, + { url = "https://files.pythonhosted.org/packages/6f/36/163da64de88a13db79214ef75fa041be7fa13bdb42261cf5b7484de14bfb/markupsafe-3.0.4-cp313-cp313-win_amd64.whl", hash = "sha256:1f1f9477e174582b0a1b583d60b66e1f2cf5d3fe12cee985e4aedf44766600e5", size = 14313, upload-time = "2026-10-02T23:05:28.749Z" }, + { url = "https://files.pythonhosted.org/packages/9f/a8/9b662783ffaa1149221432a923cee562f78b9cbbb8baa3df9b3753e63e1e/markupsafe-3.0.4-cp313-cp313-win_arm64.whl", hash = "sha256:06de8ef6331f6e822c28d577dc8bf43fe398800477c49498f38fc38b67ff33fc", size = 14189, upload-time = "2026-10-02T23:05:29.917Z" }, + { url = "https://files.pythonhosted.org/packages/5c/c3/a944f3b0df22bd129e96915b9f4e98d2eeca6516687d7618304a966c3c74/markupsafe-3.0.4-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:4ed644d75aa94a2baf7ec3a96eaa160ea58c742eb9d27c6506053c5c40fc84ed", size = 12787, upload-time = "2026-10-02T23:05:30.971Z" }, + { url = "https://files.pythonhosted.org/packages/d4/d6/a44863f69d88b6c7e27889108f70d47aed259edf89d5df3c5fca1eac87d6/markupsafe-3.0.4-cp314-cp314-android_24_x86_64.whl", hash = "sha256:6d2a9efe686f9de00d0d1ea32a4a5a86d558a2277501bd78d964214eab625e59", size = 12525, upload-time = "2026-10-02T23:05:32.263Z" }, + { url = "https://files.pythonhosted.org/packages/17/8f/168ba80e532dd6a93f96f8f706f1ad41d7990b6e1aeedc1cc0d211a33497/markupsafe-3.0.4-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:8781a792a070cf2bd1b86d3aa943894115faaba6e88122a7bf32d62072742453", size = 11589, upload-time = "2026-10-02T23:05:33.251Z" }, + { url = "https://files.pythonhosted.org/packages/32/b3/aa2c95a574d3af39403a469b295886eb9b6d448da568cbebb5a2cbfdc2e5/markupsafe-3.0.4-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:971a3bbb75d97ae4e2e8f7d4834236f86f85f0c85e04ab2e191db1123b04f80b", size = 11801, upload-time = "2026-10-02T23:05:34.315Z" }, + { url = "https://files.pythonhosted.org/packages/60/d0/34b810107d83840e768bf485de795893ebbae35b26ab061b487adfa0a692/markupsafe-3.0.4-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:8909c2f1c6dd65e054ac4b573a91c8384d1492281e55d82d159d653f7a13adf6", size = 11447, upload-time = "2026-10-02T23:05:35.302Z" }, + { url = "https://files.pythonhosted.org/packages/6c/ab/2f8488f0f817a39fca068d2b17daf446bf5cdb3eae28c3720af534d873b4/markupsafe-3.0.4-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:4cf3468d5ec187ffffcaca8e61929a37448f215dafc1386a12c750a72fe53634", size = 11722, upload-time = "2026-10-02T23:05:36.363Z" }, + { url = "https://files.pythonhosted.org/packages/ad/40/e2d117b048d47282ade906fbfd92814cbee5647afc13fda88a3406039372/markupsafe-3.0.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:52704c5d36eb6dda8866493decd61111fff86244c9b1ad225ca01b9e91e5970f", size = 12061, upload-time = "2026-10-02T23:05:37.397Z" }, + { url = "https://files.pythonhosted.org/packages/9a/a8/73a81135e85ba66217f5af7facb03bbb386807e1a729ab64532e4c802652/markupsafe-3.0.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1caa2fa5a6184fb233153b35f654e6687bd555476f6170f29d8ee9be1a8b0af9", size = 24434, upload-time = "2026-10-02T23:05:38.407Z" }, + { url = "https://files.pythonhosted.org/packages/ac/ca/fa9216dd01efee2dfdacafe7df32b4d0170fbac694b0c258a193d6e53999/markupsafe-3.0.4-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:387d8cd30e69b3f0a72877b9ae717033396404e19095b17fe89753a981fda44f", size = 27183, upload-time = "2026-10-02T23:05:39.581Z" }, + { url = "https://files.pythonhosted.org/packages/fa/4e/a469509e538d37af51103b17b073126973f2b1cbf197ff32c7ddf025cfe5/markupsafe-3.0.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:051417f74bcaaefa316276e0ff723f541616ca51043d070da00249d9bddd3e3c", size = 24813, upload-time = "2026-10-02T23:05:40.671Z" }, + { url = "https://files.pythonhosted.org/packages/8f/db/d7282caf7ab03af44d5d6fdbaa019b35c7d7f1c90588b839c07cba640d6a/markupsafe-3.0.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a8e9f292fcda89b324f2f5c91d13f1424a153e40fc2756f38ee23b15835ff300", size = 23049, upload-time = "2026-10-02T23:05:41.864Z" }, + { url = "https://files.pythonhosted.org/packages/30/f3/b6a425206e6964efda6acee544d0eb01d1501784d0b8e2dcc74986f33b17/markupsafe-3.0.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:df1ae86ff54725a01fa1a0510b914ca53a161b7050be74f6204e24aded5971d0", size = 22088, upload-time = "2026-10-02T23:05:43.014Z" }, + { url = "https://files.pythonhosted.org/packages/ea/8a/84d3582fc1f0d5bd466cdf2eebf175e172158a6e70701aacec1de1b35430/markupsafe-3.0.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:8965520ac587c94a4ac48b729be3d8b8de00af39699b17585dfb599babe77977", size = 23846, upload-time = "2026-10-02T23:05:44.098Z" }, + { url = "https://files.pythonhosted.org/packages/1c/65/db101cce51b7ba4864ac491a9859d297dd1adf0e55b103fee9db9c47c527/markupsafe-3.0.4-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:340cbb1957ba99929cbf19a75626d36ba1ae21d1730b287d1cf7f824a20c4fc7", size = 22674, upload-time = "2026-10-02T23:05:45.23Z" }, + { url = "https://files.pythonhosted.org/packages/e0/49/ddee9813d71db0c7a5c9d97c832125e6758a0c844777f1cf076569bb0e22/markupsafe-3.0.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:3a93d9616ddecfb393727a0041a562cf0b15a244e20f2bd25efc7949be4c4f17", size = 24104, upload-time = "2026-10-02T23:05:46.398Z" }, + { url = "https://files.pythonhosted.org/packages/aa/0e/7d8518d726726870a2399d69fd30d0fa36c5e57a2132c336b58d7c491073/markupsafe-3.0.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:d2e56fd3b00222722abfb3f5f0759ddbae4b90811b5ad4343c64030ad1bde70c", size = 21637, upload-time = "2026-10-02T23:05:47.48Z" }, + { url = "https://files.pythonhosted.org/packages/b4/b0/b505e8a361ba557dbf3b3aa7331ea39b00d2022a26e925ff8463b9714bb3/markupsafe-3.0.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:0d9c47709875fdb321452056622e930c52afbc07a7d780762fbb8b4d91ce6fa4", size = 23081, upload-time = "2026-10-02T23:05:48.611Z" }, + { url = "https://files.pythonhosted.org/packages/1c/ea/9cc3cea873f980c75cbdb6f4277ce30ee955de38be0b3d02f14c108e0698/markupsafe-3.0.4-cp314-cp314-win32.whl", hash = "sha256:38fc55594dab834470b6733dead2ee9e3f657fb0608c769dcafa0ba5ab52f45c", size = 14272, upload-time = "2026-10-02T23:05:49.707Z" }, + { url = "https://files.pythonhosted.org/packages/80/f0/5792ff768a410f93ee3f84fc19345295ffc352d2c936b424cb37e514714c/markupsafe-3.0.4-cp314-cp314-win_amd64.whl", hash = "sha256:c1bc67752d5f21013cfe430df4062441714eab79f65a6a05e01505957e9c35fe", size = 14501, upload-time = "2026-10-02T23:05:50.788Z" }, + { url = "https://files.pythonhosted.org/packages/5f/cf/3d074a8edffcc6899355232ff2543ae8d929733239596423b7db79698bc9/markupsafe-3.0.4-cp314-cp314-win_arm64.whl", hash = "sha256:7e1636da3d8dfc220b6dd10264db5f2b165e4888c4518594898fbe381049af8a", size = 14364, upload-time = "2026-10-02T23:05:51.857Z" }, + { url = "https://files.pythonhosted.org/packages/d9/31/87ce42159aae2163cf3bbbd0c44bc87780510eecab1ea3859099aed95dcb/markupsafe-3.0.4-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:805c8b84534fa10891890f0e4be39f3a99e94615d93e8836bf9fa1fdca2feeb2", size = 11759, upload-time = "2026-10-02T23:05:52.951Z" }, + { url = "https://files.pythonhosted.org/packages/5f/53/b047207eeb7752e960aca3eb1df5fb7eefa7dd4c62ac49bb156456c8a702/markupsafe-3.0.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:fa95848c929b6a75f6848d3c9793e59db365ee436776e57db835cdbfa79ba977", size = 12096, upload-time = "2026-10-02T23:05:54.066Z" }, + { url = "https://files.pythonhosted.org/packages/ee/51/4326c88a13c7b755657d44b4bb986f8c3d9843ecba7e22d98661d87f9a57/markupsafe-3.0.4-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e916035e3e9930cbdfdd10abf48861340221857f45509565898e012263f7b289", size = 25699, upload-time = "2026-10-02T23:05:55.15Z" }, + { url = "https://files.pythonhosted.org/packages/f2/bb/990581b7474bfcf2cf34bed6ba5ea23bd87adb9d671213d68e88620e7a6b/markupsafe-3.0.4-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:b4d12837e0203bbace818ff4a7461afdcd78bcd782351cea148139180d7bcffe", size = 27804, upload-time = "2026-10-02T23:05:56.29Z" }, + { url = "https://files.pythonhosted.org/packages/6b/89/89491878c28e8291f5aa2fffe2c2d57230d10ae366d55dd810b840513d78/markupsafe-3.0.4-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5086f9975abb1ab531ee6afca1761e4b59a19b446f3f6522ed776963228cfe5a", size = 25912, upload-time = "2026-10-02T23:05:57.416Z" }, + { url = "https://files.pythonhosted.org/packages/30/77/680998b54efdea06fc114565cd739b6d059f826a0279219b218dfa750d29/markupsafe-3.0.4-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b4a635a0487774f841cb1fb62e907e7195cc95bc761e053184b8acc3ceb20733", size = 23623, upload-time = "2026-10-02T23:05:58.557Z" }, + { url = "https://files.pythonhosted.org/packages/ae/75/2709f5ac5de9467b40b10e2bb8f89cc63dfb74582e09aa734b1124a217de/markupsafe-3.0.4-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cb96e6e088d6cf71c1ea977510948320234824cf226e32f6f6e044f7a9c82b34", size = 23301, upload-time = "2026-10-02T23:05:59.94Z" }, + { url = "https://files.pythonhosted.org/packages/a0/c8/39eadc6c5b14c9c7679bfb98f4d4c6a97863b5beb91839aca4d2d6e16e55/markupsafe-3.0.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8b5d563170ff8ba3181caa967c99a3c804d1dedb702c7cb93a6a7c32247da978", size = 24950, upload-time = "2026-10-02T23:06:01.289Z" }, + { url = "https://files.pythonhosted.org/packages/1a/5e/01037f8a43e8ccb0bffb4fbdc5212db05bf080fdd7286cd392332d58128a/markupsafe-3.0.4-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:396ec4e65cc889f69786b3b89478b471cee5a3bcf468b9d9bb03e1a30fb291fc", size = 23330, upload-time = "2026-10-02T23:06:02.441Z" }, + { url = "https://files.pythonhosted.org/packages/d4/f4/23e83ce0596bb0cbe670502d31df8f757bbd01a392aa486fa3b40d1ed399/markupsafe-3.0.4-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:15ba9e28640feef770374b116a6f019c21f52404aeabe516aa7f800587b98cfc", size = 25239, upload-time = "2026-10-02T23:06:03.579Z" }, + { url = "https://files.pythonhosted.org/packages/88/5b/3708897368073cc683d524750474f41a77d2986152c380dcc55b20fdf340/markupsafe-3.0.4-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:d920abdfa61279ba1a2ef9484aab07bf03331f8c08a10120fa332353d06e6932", size = 22767, upload-time = "2026-10-02T23:06:04.699Z" }, + { url = "https://files.pythonhosted.org/packages/c6/61/ebda1307864b409e6b3115757a3d4a09cca46cfb6cc65191b5de226b424b/markupsafe-3.0.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:a9f54054101545a9a9cccefddf54316aa6e4491611fcbef9e91b3b6bebec04f6", size = 23668, upload-time = "2026-10-02T23:06:05.9Z" }, + { url = "https://files.pythonhosted.org/packages/09/15/98075cceac3b5ba0dbb8e4762a847be967d2befc349a2cf2d0ac77f62c9d/markupsafe-3.0.4-cp314-cp314t-win32.whl", hash = "sha256:12a606a492de952afcb43b59a14aaaaad120e708d3663dd0fdf2d738d427a691", size = 14301, upload-time = "2026-10-02T23:06:07.109Z" }, + { url = "https://files.pythonhosted.org/packages/0b/a3/768b560fcc4156685cb563d922b217810cfa7bc135773367f62f1f9d2078/markupsafe-3.0.4-cp314-cp314t-win_amd64.whl", hash = "sha256:a18f38cafc329bac5e3c2b96c765b4c96d3d103421ed22ab7988c1e3fce27464", size = 14526, upload-time = "2026-10-02T23:06:08.276Z" }, + { url = "https://files.pythonhosted.org/packages/93/63/da554b4c97a6b0ea3229ca7fe8cbfb620be81613d517f482e85958550537/markupsafe-3.0.4-cp314-cp314t-win_arm64.whl", hash = "sha256:eba154571c16e032112afac0dc2dfe9e63c2ceb7aedd07bb7eecf2ce26d4dd4c", size = 14421, upload-time = "2026-10-02T23:06:09.402Z" }, +] + +[[package]] +name = "mcp" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "httpx2" }, + { name = "jsonschema" }, + { name = "mcp-types" }, + { name = "opentelemetry-api" }, + { name = "pydantic" }, + { name = "pyjwt", extra = ["crypto"] }, + { name = "python-multipart" }, + { name = "pywin32", marker = "sys_platform == 'win32'" }, + { name = "sse-starlette" }, + { name = "starlette" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, + { name = "uvicorn", marker = "sys_platform != 'emscripten'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9d/8d/e0d339616f4810e9051d4aba6887afab289ab1f81875fe908b606cdfd0e3/mcp-2.3.0.tar.gz", hash = "sha256:8b147a50441cf059dc88c684e0aeed3687f0aa0f39c6cde7b90330effd2b34d8", size = 4171955, upload-time = "2026-10-02T22:06:56.091Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/20/d7/4a2878f128df729db3ca04e71bfce26405cc2e0c9ebe19c592feeb4c61b9/mcp-2.3.0-py3-none-any.whl", hash = "sha256:dd0c44c089d16453e8ae31a3877a0054d7a2314caaa81f5e0541b9b1734b2377", size = 368664, upload-time = "2026-10-02T22:06:52.119Z" }, +] + +[[package]] +name = "mcp-types" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9e/2d/7c251e34207f6c51000312fc8839111ac45cfe02023f90b44e7f1051dd8e/mcp_types-2.3.0.tar.gz", hash = "sha256:d1e46549edb35ee19a94940fcee6d1addd7e589ab7ea92dda83f5d84781fc362", size = 65898, upload-time = "2026-10-02T22:06:57.863Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ce/48/32583066b155bb5d4507e7887dc88ee23118cc4de8e2a12870c49719e2a8/mcp_types-2.3.0-py3-none-any.whl", hash = "sha256:968efdbdaedfab06adae40d378a34395f1090c5921d4be3c9cde283aaf76d91d", size = 69108, upload-time = "2026-10-02T22:06:54.099Z" }, +] + +[[package]] +name = "mdurl" +version = "0.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, +] + +[[package]] +name = "mpmath" +version = "1.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e0/47/dd32fa426cc72114383ac549964eecb20ecfd886d1e5ccf5340b55b02f57/mpmath-1.3.0.tar.gz", hash = "sha256:7a28eb2a9774d00c7bc92411c19a89209d5da7c4c9a9e227be8330a23a25b91f", size = 508106, upload-time = "2023-03-07T16:47:11.061Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/43/e3/7d92a15f894aa0c9c4b49b8ee9ac9850d6e63b03c9c32c0367a13ae62209/mpmath-1.3.0-py3-none-any.whl", hash = "sha256:a0b2b9fe80bbcd81a6647ff13108738cfb482d481d826cc0e02f5b35e5c88d2c", size = 536198, upload-time = "2023-03-07T16:47:09.197Z" }, +] + +[[package]] +name = "mypy" +version = "1.20.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "librt", marker = "platform_python_implementation != 'PyPy'" }, + { name = "mypy-extensions" }, + { name = "pathspec" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/04/af/e3d4b3e9ec91a0ff9aabfdb38692952acf49bbb899c2e4c29acb3a6da3ae/mypy-1.20.2.tar.gz", hash = "sha256:e8222c26daaafd9e8626dec58ae36029f82585890589576f769a650dd20fd665", size = 3817349, upload-time = "2026-04-21T17:12:28.473Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/71/4e/7560e4528db9e9b147e4c0f22660466bf30a0a1fe3d63d1b9d3b0fd354ee/mypy-1.20.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:4dbfcf869f6b0517f70cf0030ba6ea1d6645e132337a7d5204a18d8d5636c02b", size = 14539393, upload-time = "2026-04-21T17:07:12.52Z" }, + { url = "https://files.pythonhosted.org/packages/32/d9/34a5efed8124f5a9234f55ac6a4ced4201e2c5b81e1109c49ad23190ec8c/mypy-1.20.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4b6481b228d072315b053210b01ac320e1be243dc17f9e5887ef167f23f5fae4", size = 13361642, upload-time = "2026-04-21T17:06:53.742Z" }, + { url = "https://files.pythonhosted.org/packages/d1/14/eb377acf78c03c92d566a1510cda8137348215b5335085ef662ab82ecd3a/mypy-1.20.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:34397cdced6b90b836e38182076049fdb41424322e0b0728c946b0939ebdf9f6", size = 13740347, upload-time = "2026-04-21T17:12:04.73Z" }, + { url = "https://files.pythonhosted.org/packages/b9/94/7e4634a32b641aa1c112422eed1bbece61ee16205f674190e8b536f884de/mypy-1.20.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a5da6976f20cae27059ea8d0c86e7cef3de720e04c4bb9ee18e3690fdb792066", size = 14734042, upload-time = "2026-04-21T17:07:43.16Z" }, + { url = "https://files.pythonhosted.org/packages/7a/f3/f7e62395cb7f434541b4491a01149a4439e28ace4c0c632bbf5431e92d1f/mypy-1.20.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:56908d7e08318d39f85b1f0c6cfd47b0cac1a130da677630dac0de3e0623e102", size = 14964958, upload-time = "2026-04-21T17:11:00.665Z" }, + { url = "https://files.pythonhosted.org/packages/3e/0d/47e3c3a0ec2a876e35aeac365df3cac7776c36bbd4ed18cc521e1b9d255b/mypy-1.20.2-cp312-cp312-win_amd64.whl", hash = "sha256:d52ad8d78522da1d308789df651ee5379088e77c76cb1994858d40a426b343b9", size = 10911340, upload-time = "2026-04-21T17:10:49.179Z" }, + { url = "https://files.pythonhosted.org/packages/d6/b2/6c852d72e0ea8b01f49da817fb52539993cde327e7d010e0103dc12d0dac/mypy-1.20.2-cp312-cp312-win_arm64.whl", hash = "sha256:785b08db19c9f214dc37d65f7c165d19a30fcecb48abfa30f31b01b5acaabb58", size = 9833947, upload-time = "2026-04-21T17:09:05.267Z" }, + { url = "https://files.pythonhosted.org/packages/5b/c4/b93812d3a192c9bcf5df405bd2f30277cd0e48106a14d1023c7f6ed6e39b/mypy-1.20.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:edfbfca868cdd6bd8d974a60f8a3682f5565d3f5c99b327640cedd24c4264026", size = 14524670, upload-time = "2026-04-21T17:10:30.737Z" }, + { url = "https://files.pythonhosted.org/packages/f3/47/42c122501bff18eaf1e8f457f5c017933452d8acdc52918a9f59f6812955/mypy-1.20.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e2877a02380adfcdbc69071a0f74d6e9dbbf593c0dc9d174e1f223ffd5281943", size = 13336218, upload-time = "2026-04-21T17:08:44.069Z" }, + { url = "https://files.pythonhosted.org/packages/92/8f/75bbc92f41725fbd585fb17b440b1119b576105df1013622983e18640a93/mypy-1.20.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7488448de6007cd5177c6cea0517ac33b4c0f5ee9b5e9f2be51ce75511a85517", size = 13724906, upload-time = "2026-04-21T17:08:01.02Z" }, + { url = "https://files.pythonhosted.org/packages/a1/32/4c49da27a606167391ff0c39aa955707a00edc500572e562f7c36c08a71f/mypy-1.20.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bb9c2fa06887e21d6a3a868762acb82aec34e2c6fd0174064f27c93ede68ad15", size = 14726046, upload-time = "2026-04-21T17:11:22.354Z" }, + { url = "https://files.pythonhosted.org/packages/7f/fc/4e354a1bd70216359deb0c9c54847ee6b32ef78dfb09f5131ff99b494078/mypy-1.20.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9d56a78b646f2e3daa865bc70cd5ec5a46c50045801ca8ff17a0c43abc97e3ee", size = 14955587, upload-time = "2026-04-21T17:12:16.033Z" }, + { url = "https://files.pythonhosted.org/packages/62/b2/c0f2056e9eb8f08c62cafd9715e4584b89132bdc832fcf85d27d07b5f3e5/mypy-1.20.2-cp313-cp313-win_amd64.whl", hash = "sha256:2a4102b03bb7481d9a91a6da8d174740c9c8c4401024684b9ca3b7cc5e49852f", size = 10922681, upload-time = "2026-04-21T17:06:35.842Z" }, + { url = "https://files.pythonhosted.org/packages/e5/14/065e333721f05de8ef683d0aa804c23026bcc287446b61cac657b902ccac/mypy-1.20.2-cp313-cp313-win_arm64.whl", hash = "sha256:a95a9248b0c6fd933a442c03c3b113c3b61320086b88e2c444676d3fd1ca3330", size = 9830560, upload-time = "2026-04-21T17:07:51.023Z" }, + { url = "https://files.pythonhosted.org/packages/ae/d1/b4ec96b0ecc620a4443570c6e95c867903428cfcde4206518eafdd5880c3/mypy-1.20.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:419413398fe250aae057fd2fe50166b61077083c9b82754c341cf4fd73038f30", size = 14524561, upload-time = "2026-04-21T17:06:27.325Z" }, + { url = "https://files.pythonhosted.org/packages/3a/63/d2c2ff4fa66bc49477d32dfa26e8a167ba803ea6a69c5efb416036909d30/mypy-1.20.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:e73c07f23009962885c197ccb9b41356a30cc0e5a1d0c2ea8fd8fb1362d7f924", size = 13363883, upload-time = "2026-04-21T17:11:11.239Z" }, + { url = "https://files.pythonhosted.org/packages/2a/56/983916806bf4eddeaaa2c9230903c3669c6718552a921154e1c5182c701f/mypy-1.20.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0c64e5973df366b747646fc98da921f9d6eba9716d57d1db94a83c026a08e0fb", size = 13742945, upload-time = "2026-04-21T17:08:34.181Z" }, + { url = "https://files.pythonhosted.org/packages/19/65/0cd9285ab010ee8214c83d67c6b49417c40d86ce46f1aa109457b5a9b8d7/mypy-1.20.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a65aa591af023864fd08a97da9974e919452cfe19cb146c8a5dc692626445dc", size = 14706163, upload-time = "2026-04-21T17:05:15.51Z" }, + { url = "https://files.pythonhosted.org/packages/94/97/48ff3b297cafcc94d185243a9190836fb1b01c1b0918fff64e941e973cc9/mypy-1.20.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:4fef51b01e638974a6e69885687e9bd40c8d1e09a6cd291cca0619625cf1f558", size = 14938677, upload-time = "2026-04-21T17:05:39.562Z" }, + { url = "https://files.pythonhosted.org/packages/fd/a1/1b4233d255bdd0b38a1f284feeb1c143ca508c19184964e22f8d837ec851/mypy-1.20.2-cp314-cp314-win_amd64.whl", hash = "sha256:913485a03f1bcf5d279409a9d2b9ed565c151f61c09f29991e5faa14033da4c8", size = 11089322, upload-time = "2026-04-21T17:06:44.29Z" }, + { url = "https://files.pythonhosted.org/packages/78/c2/ce7ee2ba36aeb954ba50f18fa25d9c1188578654b97d02a66a15b6f09531/mypy-1.20.2-cp314-cp314-win_arm64.whl", hash = "sha256:c3bae4f855d965b5453784300c12ffc63a548304ac7f99e55d4dc7c898673aa3", size = 10017775, upload-time = "2026-04-21T17:07:20.732Z" }, + { url = "https://files.pythonhosted.org/packages/4e/a1/9d93a7d0b5859af0ead82b4888b46df6c8797e1bc5e1e262a08518c6d48e/mypy-1.20.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:2de3dcea53babc1c3237a19002bc3d228ce1833278f093b8d619e06e7cc79609", size = 15549002, upload-time = "2026-04-21T17:08:23.107Z" }, + { url = "https://files.pythonhosted.org/packages/00/d2/09a6a10ee1bf0008f6c144d9676f2ca6a12512151b4e0ad0ff6c4fac5337/mypy-1.20.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:52b176444e2e5054dfcbcb8c75b0b719865c96247b37407184bbfca5c353f2c2", size = 14401942, upload-time = "2026-04-21T17:07:31.837Z" }, + { url = "https://files.pythonhosted.org/packages/57/da/9594b75c3c019e805250bed3583bdf4443ff9e6ef08f97e39ae308cb06f2/mypy-1.20.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:688c3312e5dadb573a2c69c82af3a298d43ecf9e6d264e0f95df960b5f6ac19c", size = 15041649, upload-time = "2026-04-21T17:09:34.653Z" }, + { url = "https://files.pythonhosted.org/packages/97/77/f75a65c278e6e8eba2071f7f5a90481891053ecc39878cc444634d892abe/mypy-1.20.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:29752dbbf8cc53f89f6ac096d363314333045c257c9c75cbd189ca2de0455744", size = 15864588, upload-time = "2026-04-21T17:11:44.936Z" }, + { url = "https://files.pythonhosted.org/packages/d7/46/1a4e1c66e96c1a3246ddf5403d122ac9b0a8d2b7e65730b9d6533ba7a6d3/mypy-1.20.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:803203d2b6ea644982c644895c2f78b28d0e208bba7b27d9b921e0ec5eb207c6", size = 16093956, upload-time = "2026-04-21T17:10:17.683Z" }, + { url = "https://files.pythonhosted.org/packages/5a/2c/78a8851264dec38cd736ca5b8bc9380674df0dd0be7792f538916157716c/mypy-1.20.2-cp314-cp314t-win_amd64.whl", hash = "sha256:9bcb8aa397ff0093c824182fd76a935a9ba7ad097fcbef80ae89bf6c1731d8ec", size = 12568661, upload-time = "2026-04-21T17:11:54.473Z" }, + { url = "https://files.pythonhosted.org/packages/83/01/cd7318aa03493322ce275a0e14f4f52b8896335e4e79d4fb8153a7ad2b77/mypy-1.20.2-cp314-cp314t-win_arm64.whl", hash = "sha256:e061b58443f1736f8a37c48978d7ab581636d6ab03e3d4f99e3fa90463bb9382", size = 10389240, upload-time = "2026-04-21T17:09:42.719Z" }, + { url = "https://files.pythonhosted.org/packages/28/9a/f23c163e25b11074188251b0b5a0342625fc1cdb6af604757174fa9acc9b/mypy-1.20.2-py3-none-any.whl", hash = "sha256:a94c5a76ab46c5e6257c7972b6c8cff0574201ca7dc05647e33e795d78680563", size = 2637314, upload-time = "2026-04-21T17:05:54.5Z" }, +] + +[[package]] +name = "mypy-extensions" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/6e/371856a3fb9d31ca8dac321cda606860fa4548858c0cc45d9d1d4ca2628b/mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558", size = 6343, upload-time = "2025-04-22T14:54:24.164Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" }, +] + +[[package]] +name = "narwhals" +version = "2.27.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/bf/21/f64d6b2dbea7bf3f8c38cdc786dcc6ef012ca3d173ad208c782c9a7bedf6/narwhals-2.27.1.tar.gz", hash = "sha256:aed93076a3ea42d9c32c88e4eb5ea422a21937011cbe1f480f9572a523c82094", size = 735013, upload-time = "2026-10-10T06:52:18.113Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/89/5d4c86da1130d9059681e5b6cd7645df5c10279a6a079c5c37dcb2cc6f3f/narwhals-2.27.1-py3-none-any.whl", hash = "sha256:d057df13f5852b8e157596e82eb5e955fad267425df5e420e0ee9863da483b31", size = 483211, upload-time = "2026-10-10T06:52:16.32Z" }, +] + +[[package]] +name = "networkx" +version = "3.7" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/dc/76/3af777226b63a5e64a6b36b1ec5855c14e2b94a37096d4760e595fc43511/networkx-3.7.tar.gz", hash = "sha256:fd77a511bd90f39f3d016351345b52cf5319b813bdca01de3f755d3cca62e96a", size = 1866482, upload-time = "2026-09-21T16:45:16.974Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/cd/fe58041e9011f307c490e3e17dd48cc516448f7c698a3f2d9d9d65d7e6a8/networkx-3.7-py3-none-any.whl", hash = "sha256:e3fd2c13a7814cee3746340d8d7f8598a67f16a58bf47fb7f8793fab6efca1b0", size = 2142205, upload-time = "2026-09-21T16:45:14.609Z" }, +] + +[[package]] +name = "numpy" +version = "2.5.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/95/b0/c7453d0b6e2073c3264468b106ee1563750cecc910965e67357e3698c83e/numpy-2.5.4.tar.gz", hash = "sha256:9a94cf751c9ad8ebaa835bcd3d40dacf8534ad086b88c38029b65123c7999d2a", size = 20866315, upload-time = "2026-10-10T20:05:31.422Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d0/97/ba2074e92b7befea137e77ea8471e768bbd87c339b7e8c9f5a931949f977/numpy-2.5.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c6342f54c67093cae5c0227eb0eb772fdb79f2a2c37a6eb278b9909ee06aa356", size = 17001609, upload-time = "2026-10-10T20:02:40.843Z" }, + { url = "https://files.pythonhosted.org/packages/ff/a9/bac826765e971d8e16e2064e9ac7525fd69b40ac17c905033a7f5442023f/numpy-2.5.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b11e8fda06a7d69f15ebf542660b74466c2e51094800c1fb794f47ad4faeef17", size = 12015718, upload-time = "2026-10-10T20:02:43.45Z" }, + { url = "https://files.pythonhosted.org/packages/31/2f/5ea3570fcb8ccd0882bea99436a513b2c85dad8f774a2057849130a8fb99/numpy-2.5.4-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:9cb18a327b49c5c337f972b03682f6a49855525faaf3c0d3e9c96cd0fd8880a8", size = 5451717, upload-time = "2026-10-10T20:02:46.169Z" }, + { url = "https://files.pythonhosted.org/packages/34/f2/b4fc1bafca03868220b5eaf729d2f21ebd7d7b151c0f9e144fe212bbca35/numpy-2.5.4-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:aec3fc4b32ff82421274f5d205c559c51c840c8df66a78efd7f3612dd005a26a", size = 6789926, upload-time = "2026-10-10T20:02:48.139Z" }, + { url = "https://files.pythonhosted.org/packages/dc/96/8319e2457ae4333c62c815c7006b869a4f60985c1e01024c2f8c6c040fe5/numpy-2.5.4-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fe4d21ab149f15e4e6043dfb0de87e6e5f34ac176cde83060e9802981fca2ac2", size = 15695312, upload-time = "2026-10-10T20:02:50.115Z" }, + { url = "https://files.pythonhosted.org/packages/43/a3/c799c62e19c337e6d3770b08e475887fb30ce8477d3c09efca6b2f0228a6/numpy-2.5.4-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fbde6962867ee75b48b0ee29b2b9372ec5d617799dbaf38e82dc0596f2f7738a", size = 16727283, upload-time = "2026-10-10T20:02:53.186Z" }, + { url = "https://files.pythonhosted.org/packages/39/6b/3604e53fb00314d0dc1b94ec9125a1484f649c0a17480b1f0f0c7a9d6250/numpy-2.5.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:381a7a3d2e65e64c0ec302795ab9dc12bb1e73f150904699c153716177eebdaf", size = 17047890, upload-time = "2026-10-10T20:02:56.038Z" }, + { url = "https://files.pythonhosted.org/packages/4a/7a/e8b58a5289a0d464c52885de47c35a935cdd70c03a4c3ab94a5126416dd0/numpy-2.5.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b89d0aaae2fe498c648f4c4795c084db535af5bd98ef942b2a3681fb74ce8645", size = 18485839, upload-time = "2026-10-10T20:02:59.018Z" }, + { url = "https://files.pythonhosted.org/packages/6f/c9/47094f597015009f310b8c900def59065ef1ff5a6fe7b51fc65ec58ec2c6/numpy-2.5.4-cp312-cp312-win32.whl", hash = "sha256:9968ab7e49b93ac6e1c3b2239732183152c9150f16308d30b66a372cffe3483c", size = 6138936, upload-time = "2026-10-10T20:03:01.626Z" }, + { url = "https://files.pythonhosted.org/packages/12/33/fefe62073dc8acfd0f2b9ed7c003af2f50aa61555e113e6db02b8f79f145/numpy-2.5.4-cp312-cp312-win_amd64.whl", hash = "sha256:a7b1b6353e36a7e50de2973a38d705c88ee93adcf120673cee7f45a4a3fa223a", size = 12573091, upload-time = "2026-10-10T20:03:04.349Z" }, + { url = "https://files.pythonhosted.org/packages/1a/07/161270b0c2eec56e4c905f6d6d22e1b836887b2cb189d3f5820aa588e9dd/numpy-2.5.4-cp312-cp312-win_arm64.whl", hash = "sha256:aa1cce2ff3f8d953de38b76bf44602caeb69f101430208f64a10067f7cb4b1d3", size = 10521630, upload-time = "2026-10-10T20:03:06.767Z" }, + { url = "https://files.pythonhosted.org/packages/67/14/1c3ee0118a8fce08565a5d8482631608426a33af10a01077fada5dc7c119/numpy-2.5.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:2377da2dd3ba2c1200956acbab2a358c83b8e1f8531191672d1cd6ad83250d53", size = 16997729, upload-time = "2026-10-10T20:03:09.291Z" }, + { url = "https://files.pythonhosted.org/packages/83/8c/b0ea9477fb1f0d4484bbc5cba21678cc9969704d8d7f3f158d1db35f8e14/numpy-2.5.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7415db95818b39ec475a5eea54d9e3b6bc83e3912158e46da3438cdce399804d", size = 12009826, upload-time = "2026-10-10T20:03:11.946Z" }, + { url = "https://files.pythonhosted.org/packages/e2/84/6a3d75b3ba3dfe84ac0053450753d1e6d250a8bf80f66474cc46d1fb643f/numpy-2.5.4-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:6d6a71b9d9a97c03633aa12565ef2825ffa036cc1d99cfd50dacf0f128af4fe2", size = 5445803, upload-time = "2026-10-10T20:03:14.329Z" }, + { url = "https://files.pythonhosted.org/packages/61/18/bb993f267ca20b376e07092a16793a5b31ed3138751e9ba480011a14d742/numpy-2.5.4-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:d8200f16437b289a5bb927c6e184eccc3e8389bc0070fea4cd5b9e13c1757959", size = 6786220, upload-time = "2026-10-10T20:03:16.602Z" }, + { url = "https://files.pythonhosted.org/packages/db/b6/135bb0953b61dc21c6cafa14b424ae666944e4899cf140e00c2b322a1a45/numpy-2.5.4-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1c2e71b04c6cad90026e544501bbe0ab9290fa8a4d845e7e8c0d124fb429c988", size = 15689178, upload-time = "2026-10-10T20:03:18.721Z" }, + { url = "https://files.pythonhosted.org/packages/da/24/3bd070f3269dc609d8f26b2643f62ef91bb415841c0b294805aaf7fe06da/numpy-2.5.4-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6ffa07666f8da0eef81d149934a626d0d95fbd6838432a33e66245423a9062c0", size = 16718044, upload-time = "2026-10-10T20:03:21.386Z" }, + { url = "https://files.pythonhosted.org/packages/c7/8e/9d15bd356b0a019c965312b1a3c6a727cac4cae5bc40045fbc12ce4cff9c/numpy-2.5.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:2fa3328f784fc8277fc48026f6cad516f5c561c5d8e2e39b3c9e0c8f23223b34", size = 17048364, upload-time = "2026-10-10T20:03:24.468Z" }, + { url = "https://files.pythonhosted.org/packages/dc/fe/9d5b560db964f15871885f2250795d15945f8699e17ef90c0c2ff4c875b2/numpy-2.5.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:b86966fbe4ad7de710422175572bcdc75fdedadfb54bc6fab7deabccddd7780b", size = 18474904, upload-time = "2026-10-10T20:03:27.895Z" }, + { url = "https://files.pythonhosted.org/packages/e9/98/d27552990f1bd611ef3e7466adadc78312ea2df63b83aad47fdc3d3ca8df/numpy-2.5.4-cp313-cp313-win32.whl", hash = "sha256:5258bc06526964be5face2fc6f756857a3f24f21ec3e72ca131337a75b165d6c", size = 6134537, upload-time = "2026-10-10T20:03:30.511Z" }, + { url = "https://files.pythonhosted.org/packages/90/8c/140a40398a66b4471211be1affdb6ed24c486d581bd28d07b7f2fcb69540/numpy-2.5.4-cp313-cp313-win_amd64.whl", hash = "sha256:8b4d2fd2d34e5f8c9235ee787de5631a37a28402b15cb80814df973d2be54129", size = 12566113, upload-time = "2026-10-10T20:03:32.612Z" }, + { url = "https://files.pythonhosted.org/packages/34/52/01d205e5e8ccb27b2b0b141e801f22b830198c979111b0fa44771438d9a9/numpy-2.5.4-cp313-cp313-win_arm64.whl", hash = "sha256:bc39ac66a7a9a3fbd6134fda43136b60ffde99c8f4501e64e0d2b24da137babf", size = 10519523, upload-time = "2026-10-10T20:03:35.163Z" }, + { url = "https://files.pythonhosted.org/packages/99/ba/005cb5edd580d2f84d7ca3206b92dc17d4388e56e6f87ffe8f2762f83139/numpy-2.5.4-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:c668b2f0d651605b58892644b0e302c7157f7159544227758c896982ef384b18", size = 17005499, upload-time = "2026-10-10T20:03:37.961Z" }, + { url = "https://files.pythonhosted.org/packages/f3/49/fee7587c33ee35f7977f9051d7f2023d4e7246d62710c80f20c2361ea232/numpy-2.5.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:ffa6ce09a1c6a08e9667dd9c97aa0b14184e8d18f2a14b78b2a2328c9147f076", size = 12019666, upload-time = "2026-10-10T20:03:40.606Z" }, + { url = "https://files.pythonhosted.org/packages/d5/b2/c6ce165acffceb15a82c07b9cc77d391f86b3f379ba62911908ae5d34b91/numpy-2.5.4-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:956555e0603a4d38019ae6925711cb9dc43195c076a928accf7ea5d50bddfe53", size = 5455617, upload-time = "2026-10-10T20:03:43.138Z" }, + { url = "https://files.pythonhosted.org/packages/77/7f/dd85ce260a669a89be06842cf355d7353a33e6cfbc590fb8ebb947d88dc9/numpy-2.5.4-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:2c2c4afffdeb7920e445028dd71eb932cac3e704792e964bc2a232426d4f1255", size = 6791932, upload-time = "2026-10-10T20:03:44.874Z" }, + { url = "https://files.pythonhosted.org/packages/63/d6/34b0a2b0741386a63025a65a2c09caaaaaad6d0ca95b66cd65c30dd7fcb5/numpy-2.5.4-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4054173604cd8658796053f1f3bc0befb68ec1c0762c57fdad61e199256a8617", size = 15710899, upload-time = "2026-10-10T20:03:46.839Z" }, + { url = "https://files.pythonhosted.org/packages/16/d5/928078d2b28f26829b138b4a6c3980045022fb409f570657a224ae60ef4e/numpy-2.5.4-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d549420b8858885cea8838a727842249218b9c1da24dd517e25c9c7a948310a3", size = 16721710, upload-time = "2026-10-10T20:03:49.489Z" }, + { url = "https://files.pythonhosted.org/packages/f9/cf/673fd1b8f4cd78eb6320e87ec4c90ac19c095644259e3749853a405c70f4/numpy-2.5.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:823874a507a84af050493b622affde94b6f7c3a0dc22cb2801381bc03b871c00", size = 17066182, upload-time = "2026-10-10T20:03:52.25Z" }, + { url = "https://files.pythonhosted.org/packages/f3/92/a77b5061b1b3e2643928c37976d79ee173e1b171ed158b7a3c61056b41bc/numpy-2.5.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:4e263278bfb5ee6409db8aedbc4cc32973b1b82bc1e8d3c668551d04d83a7e37", size = 18480315, upload-time = "2026-10-10T20:03:55.39Z" }, + { url = "https://files.pythonhosted.org/packages/bb/1d/1486ef3d3fb2279fd93c4c43c1bbbf1ca389a19816696684409f71babaab/numpy-2.5.4-cp314-cp314-win32.whl", hash = "sha256:cfd73180400042a7c532d30c5e287bdd03c59ff9ee1b4c0316af0539e29dfe23", size = 6185739, upload-time = "2026-10-10T20:03:58.186Z" }, + { url = "https://files.pythonhosted.org/packages/52/9a/e1e512ebc948d5b9dd33b08736760f0ebbed2848fd4eda1f553088a6dcee/numpy-2.5.4-cp314-cp314-win_amd64.whl", hash = "sha256:2ca144f15135b6212a5c47b1e2aeca6e412f102f95a2d5d88d8aec77eb255de3", size = 12703552, upload-time = "2026-10-10T20:04:00.28Z" }, + { url = "https://files.pythonhosted.org/packages/2c/05/de709a982d7bbcd688a3fad71f002e9ff80c2db39e03ee726609b610f1d1/numpy-2.5.4-cp314-cp314-win_arm64.whl", hash = "sha256:468397ba3c64427474706e5c9123fe266395496714dc684294eac75cd4930d1e", size = 10803901, upload-time = "2026-10-10T20:04:02.659Z" }, + { url = "https://files.pythonhosted.org/packages/13/34/083570ada3bb2a30fbe5d77c8c6fef9141144a15d33e6f793a67e9749ab8/numpy-2.5.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1ef3aa6d7e29bb13677323114280b05acc57607fa2300e66432d665d5418a162", size = 12138695, upload-time = "2026-10-10T20:04:05.012Z" }, + { url = "https://files.pythonhosted.org/packages/94/06/1f9c24db48eef0c2d1207e3b11fffb0478e39dfd8c1e1be7476936885eed/numpy-2.5.4-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:98b053943e5a0474ec0da309d2cb9d3f18ea57f8a2067c2ab7b5f763d1068380", size = 5574615, upload-time = "2026-10-10T20:04:07.316Z" }, + { url = "https://files.pythonhosted.org/packages/da/0f/593fba2e1560e949123bc7d2fc48b5893d56e58cd4bd5a273d2fbf60b220/numpy-2.5.4-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:b64a85f40e154983960a4167d4c1d57a50c7f109b3d3264a3a984154e90a8454", size = 6889383, upload-time = "2026-10-10T20:04:09.918Z" }, + { url = "https://files.pythonhosted.org/packages/eb/9f/b799dfdce4e05e80ed4bc815c71ff343a11533b2c0ffc221cae8538cda63/numpy-2.5.4-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a813ed7719bf45463c51779e6a98d0385fe905e48447526938a4b8337333d551", size = 15753763, upload-time = "2026-10-10T20:04:12.278Z" }, + { url = "https://files.pythonhosted.org/packages/34/88/16c5f12f86f5ad2817c4d103205131fc6c8acb3d1878af05a1a4f23ec859/numpy-2.5.4-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c9b80cdf5cedba0e90d93fa5f9a333c4d65bd545cd669b71bb97ce2b703c9d73", size = 16757212, upload-time = "2026-10-10T20:04:14.799Z" }, + { url = "https://files.pythonhosted.org/packages/ff/4f/a1fe40e18a898e6a5089f4f0d891f0a493eb0574d5b34458f0fbe5aa3e5c/numpy-2.5.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:2199ed071f460487c8db2c0e5c0b564494190edb4772fe80f9aad88b2604def5", size = 17116471, upload-time = "2026-10-10T20:04:17.58Z" }, + { url = "https://files.pythonhosted.org/packages/aa/46/e923a11c78e65c1722e7aaad817c06bd591324174b9d28ce5d31eee4d432/numpy-2.5.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:64f9c9878c1938476365e11ccfb6b770f3b9e5f045ccddc514235041e6959365", size = 18524063, upload-time = "2026-10-10T20:04:20.365Z" }, + { url = "https://files.pythonhosted.org/packages/5a/fa/84ab064514440c1f64a1b21088f2c82756defdd05e07c75ab233899565b2/numpy-2.5.4-cp314-cp314t-win32.whl", hash = "sha256:64d1c8ac28a4077cf987e0a71a7a0ef7e2df70722f07f0baa42dbb7eb6938647", size = 6340926, upload-time = "2026-10-10T20:04:22.865Z" }, + { url = "https://files.pythonhosted.org/packages/7e/7e/6cd886876f435b10685db9b9f7eeb70356f99e052116f4e5f11c5792c714/numpy-2.5.4-cp314-cp314t-win_amd64.whl", hash = "sha256:067374eb538c34c745436365cf7b0112595c1d326f21ce4ff340f61230239fbb", size = 12901584, upload-time = "2026-10-10T20:04:24.99Z" }, + { url = "https://files.pythonhosted.org/packages/38/1b/3c1684f6a06f7307f2335fca6e486cb162847fb97e91d65f8eb5cabad213/numpy-2.5.4-cp314-cp314t-win_arm64.whl", hash = "sha256:e94aef2c639da4a960ad0db8e06471208d8589974953d78b61d345b4eb99e394", size = 10891152, upload-time = "2026-10-10T20:04:27.52Z" }, +] + +[[package]] +name = "nvidia-cublas" +version = "13.1.1.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "nvidia-cuda-nvrtc" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/a7/a1/0bd24ee8c8d03adac032fd2909426a00c88f8c57961b1277ded97f91119f/nvidia_cublas-13.1.1.3-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:b7a210458267ac818974c53038fbec2e969d5c99f305ab15c72522fa9f001dd5", size = 542848918, upload-time = "2026-04-08T18:46:22.985Z" }, + { url = "https://files.pythonhosted.org/packages/3b/cd/154ca20c38269e05eff77c1464e6c1da89f50a6390b565e9d82e06bc11e1/nvidia_cublas-13.1.1.3-py3-none-manylinux_2_27_x86_64.whl", hash = "sha256:37936a16db8fe4ac1f065c2139360608a543a09275cb1a1af612e08cfa065436", size = 423138758, upload-time = "2026-04-08T18:46:58.655Z" }, +] + +[[package]] +name = "nvidia-cuda-cupti" +version = "13.0.85" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/2a/80353b103fc20ce05ef51e928daed4b6015db4aaa9162ed0997090fe2250/nvidia_cuda_cupti-13.0.85-py3-none-manylinux_2_25_aarch64.whl", hash = "sha256:796bd679890ee55fb14a94629b698b6db54bcfd833d391d5e94017dd9d7d3151", size = 10310827, upload-time = "2025-09-04T08:26:42.012Z" }, + { url = "https://files.pythonhosted.org/packages/33/6d/737d164b4837a9bbd202f5ae3078975f0525a55730fe871d8ed4e3b952b0/nvidia_cuda_cupti-13.0.85-py3-none-manylinux_2_25_x86_64.whl", hash = "sha256:4eb01c08e859bf924d222250d2e8f8b8ff6d3db4721288cf35d14252a4d933c8", size = 10715597, upload-time = "2025-09-04T08:26:51.312Z" }, +] + +[[package]] +name = "nvidia-cuda-nvrtc" +version = "13.0.88" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c3/68/483a78f5e8f31b08fb1bb671559968c0ca3a065ac7acabfc7cee55214fd6/nvidia_cuda_nvrtc-13.0.88-py3-none-manylinux2010_x86_64.manylinux_2_12_x86_64.whl", hash = "sha256:ad9b6d2ead2435f11cbb6868809d2adeeee302e9bb94bcf0539c7a40d80e8575", size = 90215200, upload-time = "2025-09-04T08:28:44.204Z" }, + { url = "https://files.pythonhosted.org/packages/b7/dc/6bb80850e0b7edd6588d560758f17e0550893a1feaf436807d64d2da040f/nvidia_cuda_nvrtc-13.0.88-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d27f20a0ca67a4bb34268a5e951033496c5b74870b868bacd046b1b8e0c3267b", size = 43015449, upload-time = "2025-09-04T08:28:20.239Z" }, +] + +[[package]] +name = "nvidia-cuda-runtime" +version = "13.0.96" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/87/4f/17d7b9b8e285199c58ce28e31b5c5bbaa4d8271af06a89b6405258245de2/nvidia_cuda_runtime-13.0.96-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ef9bcbe90493a2b9d810e43d249adb3d02e98dd30200d86607d8d02687c43f55", size = 2261060, upload-time = "2025-10-09T08:55:15.78Z" }, + { url = "https://files.pythonhosted.org/packages/2e/24/d1558f3b68b1d26e706813b1d10aa1d785e4698c425af8db8edc3dced472/nvidia_cuda_runtime-13.0.96-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7f82250d7782aa23b6cfe765ecc7db554bd3c2870c43f3d1821f1d18aebf0548", size = 2243632, upload-time = "2025-10-09T08:55:36.117Z" }, +] + +[[package]] +name = "nvidia-cudnn-cu13" +version = "9.24.0.43" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "nvidia-cublas" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/ca/30/7c257e3d5cb4fecb147b93895c66e29c93f8e76d74b45bb418ff0587c4ec/nvidia_cudnn_cu13-9.24.0.43-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:a6812a554a1ff0413e9c52b84c26c050380649ab9615f9c16bded368ce9f421f", size = 650976863, upload-time = "2026-07-02T16:23:39.248Z" }, + { url = "https://files.pythonhosted.org/packages/5c/ba/791cffd048fe5b044e620df55267e3e95c0e6e07d50b41e377c03dfc910f/nvidia_cudnn_cu13-9.24.0.43-py3-none-manylinux_2_27_x86_64.whl", hash = "sha256:71f181cd810e90f9b6023b01186fe82d13d65f0ec098581ee201d39fad769e4b", size = 553099438, upload-time = "2026-07-02T16:27:42.58Z" }, +] + +[[package]] +name = "nvidia-cufft" +version = "12.0.0.61" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "nvidia-nvjitlink" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/8b/ae/f417a75c0259e85c1d2f83ca4e960289a5f814ed0cea74d18c353d3e989d/nvidia_cufft-12.0.0.61-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:2708c852ef8cd89d1d2068bdbece0aa188813a0c934db3779b9b1faa8442e5f5", size = 214053554, upload-time = "2025-09-04T08:31:38.196Z" }, + { url = "https://files.pythonhosted.org/packages/a8/2f/7b57e29836ea8714f81e9898409196f47d772d5ddedddf1592eadb8ab743/nvidia_cufft-12.0.0.61-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:6c44f692dce8fd5ffd3e3df134b6cdb9c2f72d99cf40b62c32dde45eea9ddad3", size = 214085489, upload-time = "2025-09-04T08:31:56.044Z" }, +] + +[[package]] +name = "nvidia-cufile" +version = "1.15.1.6" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3f/70/4f193de89a48b71714e74602ee14d04e4019ad36a5a9f20c425776e72cd6/nvidia_cufile-1.15.1.6-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:08a3ecefae5a01c7f5117351c64f17c7c62efa5fffdbe24fc7d298da19cd0b44", size = 1223672, upload-time = "2025-09-04T08:32:22.779Z" }, + { url = "https://files.pythonhosted.org/packages/ab/73/cc4a14c9813a8a0d509417cf5f4bdaba76e924d58beb9864f5a7baceefbf/nvidia_cufile-1.15.1.6-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:bdc0deedc61f548bddf7733bdc216456c2fdb101d020e1ab4b88d232d5e2f6d1", size = 1136992, upload-time = "2025-09-04T08:32:14.119Z" }, +] + +[[package]] +name = "nvidia-curand" +version = "10.4.0.35" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/72/7c2ae24fb6b63a32e6ae5d241cc65263ea18d08802aaae087d9f013335a2/nvidia_curand-10.4.0.35-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:133df5a7509c3e292aaa2b477afd0194f06ce4ea24d714d616ff36439cee349a", size = 61962106, upload-time = "2025-08-04T10:21:41.128Z" }, + { url = "https://files.pythonhosted.org/packages/a5/9f/be0a41ca4a4917abf5cb9ae0daff1a6060cc5de950aec0396de9f3b52bc5/nvidia_curand-10.4.0.35-py3-none-manylinux_2_27_x86_64.whl", hash = "sha256:1aee33a5da6e1db083fe2b90082def8915f30f3248d5896bcec36a579d941bfc", size = 59544258, upload-time = "2025-08-04T10:22:03.992Z" }, +] + +[[package]] +name = "nvidia-cusolver" +version = "12.0.4.66" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "nvidia-cublas" }, + { name = "nvidia-cusparse" }, + { name = "nvidia-nvjitlink" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/c8/c3/b30c9e935fc01e3da443ec0116ed1b2a009bb867f5324d3f2d7e533e776b/nvidia_cusolver-12.0.4.66-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:02c2457eaa9e39de20f880f4bd8820e6a1cfb9f9a34f820eb12a155aa5bc92d2", size = 223467760, upload-time = "2025-09-04T08:33:04.222Z" }, + { url = "https://files.pythonhosted.org/packages/5f/67/cba3777620cdacb99102da4042883709c41c709f4b6323c10781a9c3aa34/nvidia_cusolver-12.0.4.66-py3-none-manylinux_2_27_x86_64.whl", hash = "sha256:0a759da5dea5c0ea10fd307de75cdeb59e7ea4fcb8add0924859b944babf1112", size = 200941980, upload-time = "2025-09-04T08:33:22.767Z" }, +] + +[[package]] +name = "nvidia-cusparse" +version = "12.6.3.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "nvidia-nvjitlink" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/f8/94/5c26f33738ae35276672f12615a64bd008ed5be6d1ebcb23579285d960a9/nvidia_cusparse-12.6.3.3-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:80bcc4662f23f1054ee334a15c72b8940402975e0eab63178fc7e670aa59472c", size = 162155568, upload-time = "2025-09-04T08:33:42.864Z" }, + { url = "https://files.pythonhosted.org/packages/fa/18/623c77619c31d62efd55302939756966f3ecc8d724a14dab2b75f1508850/nvidia_cusparse-12.6.3.3-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2b3c89c88d01ee0e477cb7f82ef60a11a4bcd57b6b87c33f789350b59759360b", size = 145942937, upload-time = "2025-09-04T08:33:58.029Z" }, +] + +[[package]] +name = "nvidia-cusparselt-cu13" +version = "0.8.1" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/46/e1/cdc1797eadf82d3a9a575a19b33fdc871a97edbec42c00b5b5e914f4aff4/nvidia_cusparselt_cu13-0.8.1-py3-none-manylinux2014_aarch64.whl", hash = "sha256:4dca476c50bf4780d46cd0bfbd82e2bc10a08e4fef7950917ce8d7578d22a23f", size = 221051344, upload-time = "2025-09-05T18:49:51.289Z" }, + { url = "https://files.pythonhosted.org/packages/34/7d/2661f2fb3ac4302f3a246f5fc030213ac60c1fe0bce84f9783dbd831dbb7/nvidia_cusparselt_cu13-0.8.1-py3-none-manylinux2014_x86_64.whl", hash = "sha256:786ce87568c303fadb5afcc7102d454cd3040d75f6f8626f5db460d1871f4dd0", size = 170148586, upload-time = "2025-09-05T18:50:50.248Z" }, +] + +[[package]] +name = "nvidia-nccl-cu13" +version = "2.30.7" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/21/a73174c6157101bdf1ffc22b517f76ff0082613989dd9bc8f43e8034caac/nvidia_nccl_cu13-2.30.7-py3-none-manylinux_2_18_aarch64.whl", hash = "sha256:ca786ffa5a647c75d4d1f5cc72a6c4f537947e2ba8823d7c8aaf768e7a7b9f77", size = 215983881, upload-time = "2026-06-09T03:23:15.633Z" }, + { url = "https://files.pythonhosted.org/packages/3f/34/c500f90c7ae641b8e0f98965b36b8a7ac79cc8b296e8d251fe3eb592ee54/nvidia_nccl_cu13-2.30.7-py3-none-manylinux_2_18_x86_64.whl", hash = "sha256:cefa7fdb9710efd0f39c5f1be1d61ff6fc9a996c451265bd7fbdcf9455ed4b50", size = 215965170, upload-time = "2026-06-09T03:23:39.73Z" }, +] + +[[package]] +name = "nvidia-nvjitlink" +version = "13.4.92" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1d/6b/eef7a9e32872b8f41e145bf10cddc9af26e153c338852811fe9a9baddf9e/nvidia_nvjitlink-13.4.92-py3-none-manylinux2010_x86_64.manylinux_2_12_x86_64.whl", hash = "sha256:e0391f24ed94ec879b84e3da4d4ec320c879aff681f2c7a638462f7199284323", size = 42452378, upload-time = "2026-09-16T20:45:29.042Z" }, + { url = "https://files.pythonhosted.org/packages/1f/a8/1cbd4014898af8b419e69b0d7dbc63da2121ee92d92b47d59f4fe9075349/nvidia_nvjitlink-13.4.92-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:25f74fad0d654271c921ac4dca614bd6258bc21791242fc7b2289dad7ae9c099", size = 40420120, upload-time = "2026-09-16T20:45:19.163Z" }, +] + +[[package]] +name = "nvidia-nvshmem-cu13" +version = "3.4.5" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/0f/05cc9c720236dcd2db9c1ab97fff629e96821be2e63103569da0c9b72f19/nvidia_nvshmem_cu13-3.4.5-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:6dc2a197f38e5d0376ad52cd1a2a3617d3cdc150fd5966f4aee9bcebb1d68fe9", size = 60215947, upload-time = "2025-09-06T00:32:20.022Z" }, + { url = "https://files.pythonhosted.org/packages/3c/35/a9bf80a609e74e3b000fef598933235c908fcefcef9026042b8e6dfde2a9/nvidia_nvshmem_cu13-3.4.5-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:290f0a2ee94c9f3687a02502f3b9299a9f9fe826e6d0287ee18482e78d495b80", size = 60412546, upload-time = "2025-09-06T00:32:41.564Z" }, +] + +[[package]] +name = "nvidia-nvtx" +version = "13.0.85" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c2/f3/d86c845465a2723ad7e1e5c36dcd75ddb82898b3f53be47ebd429fb2fa5d/nvidia_nvtx-13.0.85-py3-none-manylinux1_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:4936d1d6780fbe68db454f5e72a42ff64d1fd6397df9f363ae786930fd5c1cd4", size = 148047, upload-time = "2025-09-04T08:29:01.761Z" }, + { url = "https://files.pythonhosted.org/packages/a8/64/3708a90d1ebe202ffdeb7185f878a3c84d15c2b2c31858da2ce0583e2def/nvidia_nvtx-13.0.85-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:cb7780edb6b14107373c835bf8b72e7a178bac7367e23da7acb108f973f157a6", size = 148878, upload-time = "2025-09-04T08:28:53.627Z" }, +] + +[[package]] +name = "opentelemetry-api" +version = "1.45.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2e/02/6e0ae9cc61bd3169d401077b507b3ebc344745171e1051ab430be012dcd9/opentelemetry_api-1.45.1.tar.gz", hash = "sha256:aa38ed19bcc084ba42782a73255b3582283eced7ad6dddbd6695189e69adfb75", size = 72804, upload-time = "2026-10-06T17:32:58.133Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/41/f7dcf80b81ee8e71c1a2b59f14208bc723edbd89ed027a73b175abf6348e/opentelemetry_api-1.45.1-py3-none-any.whl", hash = "sha256:b31553efa588ae44bc306f863c785c5333a9ecc091248c6ee68b4b6c87fdedfb", size = 60256, upload-time = "2026-10-06T17:32:33.506Z" }, +] + +[[package]] +name = "packaging" +version = "26.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/fa/3944b40b07da9ce895c0e6303a5ab7d53da063554f534556b134a54d6093/packaging-26.3.tar.gz", hash = "sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79", size = 313412, upload-time = "2026-08-04T18:15:28.737Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956, upload-time = "2026-08-04T18:15:27.159Z" }, +] + +[[package]] +name = "pathspec" +version = "1.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5a/82/42f767fc1c1143d6fd36efb827202a2d997a375e160a71eb2888a925aac1/pathspec-1.1.1.tar.gz", hash = "sha256:17db5ecd524104a120e173814c90367a96a98d07c45b2e10c2f3919fff91bf5a", size = 135180, upload-time = "2026-04-27T01:46:08.907Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f1/d9/7fb5aa316bc299258e68c73ba3bddbc499654a07f151cba08f6153988714/pathspec-1.1.1-py3-none-any.whl", hash = "sha256:a00ce642f577bf7f473932318056212bc4f8bfdf53128c78bbd5af0b9b20b189", size = 57328, upload-time = "2026-04-27T01:46:07.06Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "portalocker" +version = "3.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pywin32", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5e/77/65b857a69ed876e1951e88aaba60f5ce6120c33703f7cb61a3c894b8c1b6/portalocker-3.2.0.tar.gz", hash = "sha256:1f3002956a54a8c3730586c5c77bf18fae4149e07eaf1c29fc3faf4d5a3f89ac", size = 95644, upload-time = "2025-06-14T13:20:40.03Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4b/a6/38c8e2f318bf67d338f4d629e93b0b4b9af331f455f0390ea8ce4a099b26/portalocker-3.2.0-py3-none-any.whl", hash = "sha256:3cdc5f565312224bc570c49337bd21428bba0ef363bbcf58b9ef4a9f11779968", size = 22424, upload-time = "2025-06-14T13:20:38.083Z" }, +] + +[[package]] +name = "pycparser" +version = "3.11" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/da/a8/c5fdbeee588bb8ada9458774f43adf1bdd30bd59157055142183e769a024/pycparser-3.11.tar.gz", hash = "sha256:d875f09c3507d00e1aba0eecc6dcadc1352f30fff09dc6bff2f1c2935e97c2bc", size = 113796, upload-time = "2026-10-09T12:56:59.539Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/90/11/0e6f11117525ff0eec40ebac3d313376f102df93ca44ad9e893ee85e4f89/pycparser-3.11-py3-none-any.whl", hash = "sha256:51d5a8ba2be0bbe440b99d2112604c95bbbc3c2748a64260186c541e1729cd80", size = 51178, upload-time = "2026-10-09T12:56:58.131Z" }, +] + +[[package]] +name = "pydantic" +version = "2.14.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7c/0b/8e10b2e693af8ec54346a14caf36221334775975a747c9ceaa3f8371d96d/pydantic-2.14.1.tar.gz", hash = "sha256:94f478203dd03404682a1ada216965651dd74b1d2d5ffd62e00e0837caab5c26", size = 900362, upload-time = "2026-10-11T18:37:55.396Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/35/ea/a56b9fe5066f3537b7882f77e9c5dfb26d8c2eefdaed9b5fc73d57b4dc22/pydantic-2.14.1-py3-none-any.whl", hash = "sha256:9195d967ec791692a04438115466764fb8b9a27b31f14a760437694f40d6b454", size = 488106, upload-time = "2026-10-11T18:37:53.437Z" }, +] + +[[package]] +name = "pydantic-core" +version = "2.50.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a6/24/af4ec4be49fbc810f35b0bdcacc3d433b56bbfa469fd3bfd4ae116cd9bf1/pydantic_core-2.50.1.tar.gz", hash = "sha256:e50d7b94baac6c7d09927fa5ca5800a0c7ee5015c7fcff65beb3a1931b5a6e09", size = 513515, upload-time = "2026-10-11T18:35:44.82Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ee/94/101b49a6c63f99586755690696eae14532772587d9e39e93f7b8d28a49da/pydantic_core-2.50.1-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:704075d10b74f2f3c6e15407c696d88701df35fc8953f434a431add0d0074db0", size = 2222428, upload-time = "2026-10-11T18:32:20.228Z" }, + { url = "https://files.pythonhosted.org/packages/db/a3/f8b1b39349c33037c73d7a1b794c9c29d8f07d5bd2b74399c1375a529cc6/pydantic_core-2.50.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:e8e1d6ce820aa23317e8209a86bd65a540973c12dc7552b48a4f6c8e9926815e", size = 2025779, upload-time = "2026-10-11T18:32:21.723Z" }, + { url = "https://files.pythonhosted.org/packages/d2/2d/862a7d115305cc621358e51ba13f296e0ffdf4fbd984be9f63bc7862730b/pydantic_core-2.50.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c18db21573bd2c6489f9a544b7499f0df2853958c568e5e783536ee1f690af41", size = 2048429, upload-time = "2026-10-11T18:32:23.3Z" }, + { url = "https://files.pythonhosted.org/packages/c0/7d/fed95d18bf2b5abe2d5ab2bbec4184bfc22b6805994a33ccdae8edffd25a/pydantic_core-2.50.1-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:cb57f304525a5e3c13333b772bf9a473f36326e9c821b2e8e1b2fd36f80ae2c3", size = 2173274, upload-time = "2026-10-11T18:32:24.697Z" }, + { url = "https://files.pythonhosted.org/packages/93/1e/c6f22ceac65dc3d291ff79bea0eb74cbd1de8b128f01fee48af03bcea1e1/pydantic_core-2.50.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a27c09d86600f1bf2fe3f37e1ae697faf3143931c09322cd799da94deee923b5", size = 2360403, upload-time = "2026-10-11T18:32:26.281Z" }, + { url = "https://files.pythonhosted.org/packages/2c/97/190b5b6bfe4c72f265feb657913eb30749193927a5ad7b83cfa7866a25a2/pydantic_core-2.50.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:46b3301d3b5c886f77de7546e47274a5842c622ea2020b8c6524c6b66913b4a6", size = 2439799, upload-time = "2026-10-11T18:32:27.844Z" }, + { url = "https://files.pythonhosted.org/packages/90/24/48cd98388e4ac2c08d0800af36d15413db15e20387f1038123a2c86cb2f6/pydantic_core-2.50.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:93ba4e9d8210d941c200431a56b2c0400b131865947903937ed3ec5404307d2e", size = 2160157, upload-time = "2026-10-11T18:32:29.553Z" }, + { url = "https://files.pythonhosted.org/packages/00/86/d909133d0b9979ed14c20f110f21124a0e346caf071bd66ac5af3009062b/pydantic_core-2.50.1-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:e5faeaee74a57d32b3ab3aebad2e348f06d3ba946fc5d28c1728455f00a3d13a", size = 2229523, upload-time = "2026-10-11T18:32:31.027Z" }, + { url = "https://files.pythonhosted.org/packages/90/31/4b43f08131b2098e73a852fd932063385f3103b33839fd9d1abdb89e673d/pydantic_core-2.50.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a3cda0e538208e5d722bbf3698b24f19c0a7d05bc8d5f8a7f9b121ea7fa243d9", size = 2242700, upload-time = "2026-10-11T18:32:32.622Z" }, + { url = "https://files.pythonhosted.org/packages/88/77/de39658569dc7a4c293d110a89629f61145e7538f00d1e4280c28debefd3/pydantic_core-2.50.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:57f51b31ff826e2859120cf4737c5a758a48d96f3e97da40ccee1796d58078ff", size = 2278452, upload-time = "2026-10-11T18:32:34.165Z" }, + { url = "https://files.pythonhosted.org/packages/46/bd/1eeb9e4e791e81210ef9096f58080b32ba15b5b13c108d50aaa3f8ce9f73/pydantic_core-2.50.1-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:8daa7ee75245d43ad7d747e5c9ecc1b1d06552f72b14887e9276f787d57375f4", size = 2450098, upload-time = "2026-10-11T18:32:35.547Z" }, + { url = "https://files.pythonhosted.org/packages/1c/e6/b2accfdff17e5a88d6358536702146de00f9339adaa4d5bbe1ee93cb4832/pydantic_core-2.50.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:acbf31f37c53a5ac0c34706c80b4f5107ba20b05fdd3816124bf236ef0c57dd2", size = 2476839, upload-time = "2026-10-11T18:32:37.119Z" }, + { url = "https://files.pythonhosted.org/packages/3b/7e/c8a83eb5d2cd42c2e13507150fb777b1edea5a018adc9cbf2858a1d08285/pydantic_core-2.50.1-cp312-cp312-win32.whl", hash = "sha256:45b11cac094aa25725581d9304eee93c9028516b9ea80dd9e175e13a5a2c840e", size = 2101425, upload-time = "2026-10-11T18:32:38.593Z" }, + { url = "https://files.pythonhosted.org/packages/c5/17/8bbd530b8659e9d963f5b16f6cb8e159f4cd174b4d6304e48969ab12dba8/pydantic_core-2.50.1-cp312-cp312-win_amd64.whl", hash = "sha256:132529c83901437ff642f585216831bf5fd7a91df66829907e155192ead62498", size = 2172522, upload-time = "2026-10-11T18:32:40.112Z" }, + { url = "https://files.pythonhosted.org/packages/af/9f/a79d690b127e4e3cff143d031538ab9aa715f6b2fbeecbd07cd6f2bf9fb7/pydantic_core-2.50.1-cp312-cp312-win_arm64.whl", hash = "sha256:4e834f6a8e4ff772dcc34f58ef5504147a3ea5b0f4eeb13b0f8eb2ca75ac57f1", size = 2161366, upload-time = "2026-10-11T18:32:41.606Z" }, + { url = "https://files.pythonhosted.org/packages/ae/57/0e237d7091d2cd44a35d243b7344227f90440166860469cd036afc23a04d/pydantic_core-2.50.1-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:d5e062c01286d861fd6a1c4ff6e063547b3e713067f2df033c0ff97ac2ca006b", size = 2222774, upload-time = "2026-10-11T18:32:43.103Z" }, + { url = "https://files.pythonhosted.org/packages/f7/b9/c720e56858d4e1539503297ed37063e0c08e0f3541c41b777f6a800f75fa/pydantic_core-2.50.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0c003c3b7f49debb893d2d85ae099ac5959c9839e2f330fadb1fcdf7a6594482", size = 2024738, upload-time = "2026-10-11T18:32:44.587Z" }, + { url = "https://files.pythonhosted.org/packages/4d/b8/fbfc25875219cc060e613170ff10e850c6d8924beb4910da57c2ee3ba1d2/pydantic_core-2.50.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:409e0ea40ec30d9158f33574fd758e689f6045a0f2596701828c27816ca9687d", size = 2047049, upload-time = "2026-10-11T18:32:46.164Z" }, + { url = "https://files.pythonhosted.org/packages/d7/43/34210124d504c553688f2f04b48500b131237528ac545b445e0d6d30e0d5/pydantic_core-2.50.1-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:131059670f1d2444269b8585cb888963994871932447c08b39ac6a51fcfef658", size = 2173415, upload-time = "2026-10-11T18:32:47.722Z" }, + { url = "https://files.pythonhosted.org/packages/65/cf/6e178e8fdc11da5965bef980983bf46326a42f436871dd51ba0a57f39df1/pydantic_core-2.50.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6dbcbee53bf17196a7f745aa9bf5a9603953a1e365b1f020be3207c676a3e7c4", size = 2361719, upload-time = "2026-10-11T18:32:49.217Z" }, + { url = "https://files.pythonhosted.org/packages/11/14/bd5169d356aa91bf777e28ba0b281c192d286d03c2812c9c9db023a2f00e/pydantic_core-2.50.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:325c23f3e35cfbf0fe3486fa5f7260d1e45885173002d30a28ca019994124255", size = 2439803, upload-time = "2026-10-11T18:32:51.025Z" }, + { url = "https://files.pythonhosted.org/packages/1c/bc/d79d000e5203ebef39af839f2ce77a777fcad6e08ad26af9a2fcd114ffc8/pydantic_core-2.50.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:17e722e156d0444ecaefbe640bdb60928752bf2013e2b7a11cdb099aaae19bec", size = 2158402, upload-time = "2026-10-11T18:32:52.714Z" }, + { url = "https://files.pythonhosted.org/packages/1b/a5/4902cb5fd599422c130bd3124ab31ee8b771199bd56662702eed07d3fec7/pydantic_core-2.50.1-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:aa8224f10880d9bf1b5993988ba153d42a8b4f3f4f511f93b1f09c93ff613c72", size = 2229755, upload-time = "2026-10-11T18:32:54.126Z" }, + { url = "https://files.pythonhosted.org/packages/1a/f5/c1481f8669f6060d89110c9b1374173fc8767ca276b84f4870bd8acd5e3f/pydantic_core-2.50.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:41bc8237121bd8dc8d888dfd6279fc166ffc88c1f1bf3a8bf00869680533ca4c", size = 2242303, upload-time = "2026-10-11T18:32:55.641Z" }, + { url = "https://files.pythonhosted.org/packages/04/f9/77fc3c7653ba9b6e42049e17e96b25388187d4a7c274ab1cb07f58ac8419/pydantic_core-2.50.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:45c6266d071c241f2a168d45bf8c54344f0effce35e7e6b73afdec11f3687568", size = 2278345, upload-time = "2026-10-11T18:32:57.38Z" }, + { url = "https://files.pythonhosted.org/packages/95/9b/0579c5d12e7f2b16b27e6782427987341fcce07b0725e02ddb0b74add0c4/pydantic_core-2.50.1-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:1deeacb112d14d3f4fcb16b165f7dbaf76c70ba6e82f37ba042bdab51970a0b8", size = 2449947, upload-time = "2026-10-11T18:32:58.896Z" }, + { url = "https://files.pythonhosted.org/packages/a8/ac/1b677db91eba54cc5922f4de6edc46ba45c2bd712dfdc0130382d158e214/pydantic_core-2.50.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:1c96fd793b73d1b92e65570132505498fe7b21eaef73cdf74e67e5dfba7ac9e4", size = 2476889, upload-time = "2026-10-11T18:33:00.665Z" }, + { url = "https://files.pythonhosted.org/packages/4d/2a/3a9f6624ee3ea9ccba5249dde11418bb4c35780a7a92608f8768bd3fea39/pydantic_core-2.50.1-cp313-cp313-win32.whl", hash = "sha256:06ead20d39ffd6f2f6f2a8f8a6de67ff8bb1b4f14a8a30e058502514ee2ac685", size = 2101118, upload-time = "2026-10-11T18:33:02.329Z" }, + { url = "https://files.pythonhosted.org/packages/2d/1f/323f78ddd9d9938aac420c1abb4e8ba799fc8bdab0acc67c0593b837c979/pydantic_core-2.50.1-cp313-cp313-win_amd64.whl", hash = "sha256:7816e98acc08119dc0f340ab167048ecc54126316330c1f0caf7c6756c88e28f", size = 2170840, upload-time = "2026-10-11T18:33:03.919Z" }, + { url = "https://files.pythonhosted.org/packages/cb/09/8497c52a739ae425c3ac2f7f56414cbc711c67d374346174f40fe2062644/pydantic_core-2.50.1-cp313-cp313-win_arm64.whl", hash = "sha256:c17799a62c142d61b8a3c51752a7cbc87fe2ad4ccfab10e628a77b405075c662", size = 2161633, upload-time = "2026-10-11T18:33:05.518Z" }, + { url = "https://files.pythonhosted.org/packages/49/33/28b96e81677153715e3eafb9f26663a80841e859fde282a380359b0d3fa1/pydantic_core-2.50.1-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:1cf41f1ae3fa155cf167a72689ad044bcc1e3c97e064123677149bdfb5dafc4a", size = 2224428, upload-time = "2026-10-11T18:33:07.153Z" }, + { url = "https://files.pythonhosted.org/packages/94/40/15c06410c9b7b8da5805d27b64e09bd3f900e986728106db2689dbc51513/pydantic_core-2.50.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:4df197990c15b5a37c5a277d131d9f2c67de6133f2e5dafd80d9bba4b99f46f9", size = 2023740, upload-time = "2026-10-11T18:33:08.763Z" }, + { url = "https://files.pythonhosted.org/packages/a1/4e/5eb629f6efc2a27e421d789dd4bfacbb5f6d09c80c28b13d1e87d73163d5/pydantic_core-2.50.1-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0036473f5583e6a60e50b8b21651511564277a3f05cc5dab8cf579f552cd5f6c", size = 2049435, upload-time = "2026-10-11T18:33:10.366Z" }, + { url = "https://files.pythonhosted.org/packages/ba/8e/f195aebec49ad12318876ac2368c197a7939f5d52f8e156d2238ef8a4588/pydantic_core-2.50.1-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:992c3514ec891fa7858099183e4d64e6bd5a5d4ff452fae29df22faa77a006bb", size = 2172975, upload-time = "2026-10-11T18:33:12.368Z" }, + { url = "https://files.pythonhosted.org/packages/e0/f5/7ad9fb83010cd5ea0948409db105676ed779c4e709e2d3d89b9fe2558794/pydantic_core-2.50.1-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:739dc730e6be3bd5ec2f4ab5cfc7eb047cc45fc1497b3bafec74ff2ed07df597", size = 2364197, upload-time = "2026-10-11T18:33:14.244Z" }, + { url = "https://files.pythonhosted.org/packages/ca/fb/bf0aab3e78301d202b82a0322ec968cd703b11fc0623fde9a28708936f62/pydantic_core-2.50.1-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:32fad3a91e51b6d2039c572db04a5a873260b399f6bd62c3552671fa7a4a2899", size = 2444259, upload-time = "2026-10-11T18:33:15.79Z" }, + { url = "https://files.pythonhosted.org/packages/45/35/38f6d6564fae57d9b12e5676dfa947e0e7d5c46dbeaa7eb3352261d6d299/pydantic_core-2.50.1-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:42b54c2c90ad348b5e3a85e03e715d572c1fde357ef104cdfe3b03b697a404ea", size = 2159857, upload-time = "2026-10-11T18:33:17.51Z" }, + { url = "https://files.pythonhosted.org/packages/65/a0/fb0a3ca10f139dcf765b2d312d10cf0f65c57c59993229d00ad12b14ceff/pydantic_core-2.50.1-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:2df1ff41884de2bc4b307bafd7c40a691094fad2ff8e767e5b45a319257bcf4e", size = 2233954, upload-time = "2026-10-11T18:33:19.591Z" }, + { url = "https://files.pythonhosted.org/packages/8c/6a/e63842252702aa4ec6e6b7178ca85e541a77459076592c23ebe2d9840b33/pydantic_core-2.50.1-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fe90228920fd8ff2be62622b6bb8a2b11acd65046d50c6b130614b5879605a20", size = 2243876, upload-time = "2026-10-11T18:33:21.393Z" }, + { url = "https://files.pythonhosted.org/packages/39/25/5991cf8318b37e0dfab47b87541619a1df8501a793cba0d978846cba37a7/pydantic_core-2.50.1-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:844b869f118e22a41a091bdcedda8a71bc1b0f62c38d1a0c3211cece47e1d8fc", size = 2277908, upload-time = "2026-10-11T18:33:23.324Z" }, + { url = "https://files.pythonhosted.org/packages/a7/3e/3ee8baaa6cc25a6961c69168cf9ff0f002d56f4e0d541ad6724c18fb61f3/pydantic_core-2.50.1-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:2eb75304506894a281d346220a4f7481a1b8729577c5ed2a05395991966a8396", size = 2449285, upload-time = "2026-10-11T18:33:24.942Z" }, + { url = "https://files.pythonhosted.org/packages/c0/c7/acbec6deac13fe697a80c275a9b6661db62c4d323bac8a47347b1f39c7cd/pydantic_core-2.50.1-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:6b20a4bffabdad0db2927ac034ae3b8a681b1f7a0182f3e60b479ad2fde21ebb", size = 2479013, upload-time = "2026-10-11T18:33:26.925Z" }, + { url = "https://files.pythonhosted.org/packages/10/08/21a3f237b264389f6219d053ee78fc1b5c2fd402c1b1cb2b6cb8b85f9834/pydantic_core-2.50.1-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:99ba9bc2b8062ea0c326a990f7f00e6530c23579de66dd246e72c4cafef950a5", size = 1484400, upload-time = "2026-10-11T18:33:28.693Z" }, + { url = "https://files.pythonhosted.org/packages/09/ce/077a6d262d12ef09108377ac0717f029f420d773ace63cafd6143af75568/pydantic_core-2.50.1-cp314-cp314-win32.whl", hash = "sha256:cf356f70551d40374eaffb1aa63f1eb6d2006681cbd7a9faea173ce0f4dd7cd2", size = 2103222, upload-time = "2026-10-11T18:33:30.326Z" }, + { url = "https://files.pythonhosted.org/packages/14/4c/350a2415209c43d670eb71d3c040f332c04a30583d39e311b05c7ac15762/pydantic_core-2.50.1-cp314-cp314-win_amd64.whl", hash = "sha256:d32f3acc081cc3923386d88f422cde8892335e95f034e0104bb4cf9310d9915f", size = 2171586, upload-time = "2026-10-11T18:33:32.139Z" }, + { url = "https://files.pythonhosted.org/packages/bf/92/9bea6ca96580a0902fed366f064f0829e404f41889b34543279a1162b888/pydantic_core-2.50.1-cp314-cp314-win_arm64.whl", hash = "sha256:bed5163e03b98bc1fa2eb05d74c63d9c5c95d8ed6254985481640fbf5e237dea", size = 2155773, upload-time = "2026-10-11T18:33:33.896Z" }, + { url = "https://files.pythonhosted.org/packages/86/8c/f121f073cf32bdd5cba7e6230b1ce0ac845b0cf43e44dd597d95af272db2/pydantic_core-2.50.1-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:9572c1369e9c9da2d64a7b7992c786d90ff295abc93964cfe3125e4290768070", size = 2234017, upload-time = "2026-10-11T18:33:35.588Z" }, + { url = "https://files.pythonhosted.org/packages/a4/69/2bb2bcd6146cffe98d760a46c42ae71efd5151d9b2f9c9bf6619a3b32083/pydantic_core-2.50.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:2005207aafe1231315718bf6ed5d064a7300fb4772754af35ee72fc68159492e", size = 2001016, upload-time = "2026-10-11T18:33:37.57Z" }, + { url = "https://files.pythonhosted.org/packages/20/b3/fbf854c7d07ec114260c26e9e2071a4381740f9ae09641dbfcbdf2a18c45/pydantic_core-2.50.1-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:64f6047f62a6c5ae08d0a6afb035667aa2d97c3d20d69762e034c5ea144d92a5", size = 2023015, upload-time = "2026-10-11T18:33:39.433Z" }, + { url = "https://files.pythonhosted.org/packages/65/20/6de55b2f92cdb614b745c6e9ced639fc4fd7e1e77604825a88bdece6fcbd/pydantic_core-2.50.1-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:1ef800dd7d85bcdadf4c3076e4c94e43939493558a3b69a1ea830c706d4617bb", size = 2192543, upload-time = "2026-10-11T18:33:41.381Z" }, + { url = "https://files.pythonhosted.org/packages/0c/d9/19e91c94bd5c405945ce2f15526808aea37e162c253160de8ed7bf70b406/pydantic_core-2.50.1-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b0135bcdcaa0f23573f286e4cb5e0fd2962700964ed13df085b85f2b97aeab9e", size = 2388704, upload-time = "2026-10-11T18:33:43.167Z" }, + { url = "https://files.pythonhosted.org/packages/b6/bc/2e24c8415eae1a25ee5a5484946a917123bad2e9d01689a759236928175a/pydantic_core-2.50.1-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0b3a6f334c6a2345ca15318ff894502a90012536404b37c844a976c76c846e0b", size = 2464118, upload-time = "2026-10-11T18:33:44.856Z" }, + { url = "https://files.pythonhosted.org/packages/bd/1f/945b8053cb061c64e102bcaf7bfb9ed740c0bd4349f9bb978a7d4ddff4ab/pydantic_core-2.50.1-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:06e01fbbfdb9be777b316a71b6c49efaf4a08b615d0a98d678cda3023f79d019", size = 2150142, upload-time = "2026-10-11T18:33:46.661Z" }, + { url = "https://files.pythonhosted.org/packages/2b/78/96a3e50bf0d64aaae781107eb9335b7529d05a85793ed6e7241c4cd1d931/pydantic_core-2.50.1-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:a29a061fec0b4e2d714f277e70a3a18125ecff803f2fea6eade2f2e53711d112", size = 2245577, upload-time = "2026-10-11T18:33:48.574Z" }, + { url = "https://files.pythonhosted.org/packages/7a/5d/a6038a0322232758a6ebfa709f4ca14a60bf5b93940cd0b345056a557da4/pydantic_core-2.50.1-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f5187624823423e1d1b82b1072ac41dc837389e18d3d0572cc19bbee46cd550a", size = 2263728, upload-time = "2026-10-11T18:33:50.527Z" }, + { url = "https://files.pythonhosted.org/packages/0d/4b/76ded3333a457a9344c4f2d63f2d82d0101654b05178fa4ddfe7d3627674/pydantic_core-2.50.1-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:3e46a9eb0a0901dd6275e6b06ac3a464885ef350ec4121fe486869de8053e4bb", size = 2293630, upload-time = "2026-10-11T18:33:52.362Z" }, + { url = "https://files.pythonhosted.org/packages/a9/00/9eca378335c9c1b72bc779bf6e4c2a82ce784f14ec48a0e87102c9870e05/pydantic_core-2.50.1-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:756d669f04e62ec4148ecfe22be6a4484d9b1181a6ef32e205ebfd200540858b", size = 2468048, upload-time = "2026-10-11T18:33:54.118Z" }, + { url = "https://files.pythonhosted.org/packages/35/ca/e3832e9cf93651251de43c8c5c029ed680a3c1a0a1b17ee26c81bed08cbd/pydantic_core-2.50.1-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:c516cc5367ca3448995d42cb994bf3f4c9002d2a7c22eac9622551269ad1b807", size = 2512159, upload-time = "2026-10-11T18:33:55.99Z" }, + { url = "https://files.pythonhosted.org/packages/4b/f2/773469b5a10a39116a2cb17edaf6d722f017dd8da07161b371465311c542/pydantic_core-2.50.1-cp314-cp314t-win32.whl", hash = "sha256:9d1bed94af6a63835461f3cf7502058eb166c58c4778e11d0f433cfb1bd69e19", size = 2098406, upload-time = "2026-10-11T18:33:58.043Z" }, + { url = "https://files.pythonhosted.org/packages/ee/42/0bb74f8f25204b259b11ab7c12dc7f180893b6bd706118b385147fb6efd5/pydantic_core-2.50.1-cp314-cp314t-win_amd64.whl", hash = "sha256:c8dce1f1e0e5358b682a6ad3fa5e31b31d4560997b8e61417e9217c8d60f8a0c", size = 2148481, upload-time = "2026-10-11T18:33:59.913Z" }, + { url = "https://files.pythonhosted.org/packages/47/0d/d801646c9679a4e630e15cf521d4108b93854b42a6e6e02391bd4c6b1095/pydantic_core-2.50.1-cp314-cp314t-win_arm64.whl", hash = "sha256:ceff0acc940be2715bd6ad17b24c0e5304abf44f6efd0f81ee8499e640f9dc86", size = 2174972, upload-time = "2026-10-11T18:34:01.875Z" }, + { url = "https://files.pythonhosted.org/packages/a5/09/1bcf160f3cd6333e2a76982fae5963e5032de0bcaaba38464223dc266bfe/pydantic_core-2.50.1-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:76e2e83fa6ec8cdc972d438dafc2522b3a47bee4ec0ae668b29cfb1977ab5242", size = 2217070, upload-time = "2026-10-11T18:35:15.688Z" }, + { url = "https://files.pythonhosted.org/packages/3d/81/f02de95eec7794dfc7a5bc69a834f0e834b31b3009014ce6de903855f775/pydantic_core-2.50.1-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:a51eee75939cf811ac09b278745a6cee7dc873ccfbc8b9af3cc88fe4b7ce25b5", size = 2039027, upload-time = "2026-10-11T18:35:18.07Z" }, + { url = "https://files.pythonhosted.org/packages/4d/16/227746ed3771d9bf2304568b55134849ece34e13b38b1d76de763015c77f/pydantic_core-2.50.1-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ae28183297fb0d2b8dc46a1f01d51f5e45825fc5afe76a835a6cb7fb34821295", size = 2109009, upload-time = "2026-10-11T18:35:20.218Z" }, + { url = "https://files.pythonhosted.org/packages/e5/b9/7664d1592a0e5a74903f357cd5b28ceb7336f3473256c5a4e8c8432f1881/pydantic_core-2.50.1-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:88e492e8b9d0312e7dc13667c30222abf284dc3b79b5302b3607b41a5784ce61", size = 2269883, upload-time = "2026-10-11T18:35:22.48Z" }, +] + +[[package]] +name = "pygments" +version = "2.21.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/49/2e/ced460408999b33da6b31b0021b0f37d329e202d4169aeb164493778f25b/pygments-2.21.0.tar.gz", hash = "sha256:610ca751c9bc2492b38eb9a38a7fbc93edbbb2d7182edaf34e66ae493dee5c8c", size = 5005329, upload-time = "2026-08-17T08:02:48.824Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/71/46/17f022dd3e953bf20a04a028a21ec746d942f8d2af30fa0f124fa0e6a684/pygments-2.21.0-py3-none-any.whl", hash = "sha256:2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9", size = 1250147, upload-time = "2026-08-17T08:02:44.912Z" }, +] + +[[package]] +name = "pyjwt" +version = "2.15.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/43/ea/5194e52748b0da83d71e082d75496eaec6e58f419f5e184786ded517e6a9/pyjwt-2.15.1.tar.gz", hash = "sha256:4f259e80cdfb6b3fc18a7de51fd1ef9ec79652f25019bae68975ca2468a34df8", size = 121252, upload-time = "2026-09-28T18:40:42.598Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/50/ca/44de4e75f8aadc457f0634be3b542815078ded46dca30efb960edeecad6e/pyjwt-2.15.1-py3-none-any.whl", hash = "sha256:42d59d631f7768a1028a64c7ff581a9bf7519804daf91fc5b6c56e30eec5e193", size = 33860, upload-time = "2026-09-28T18:40:41.429Z" }, +] + +[package.optional-dependencies] +crypto = [ + { name = "cryptography" }, +] + +[[package]] +name = "pyotp" +version = "2.10.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2d/c6/c5d96a86fd0bf6fa1bbb5c5c341ff3208638b692727a683c8289068d9a11/pyotp-2.10.0.tar.gz", hash = "sha256:d01e9703443616b03c57c700b5cbffd56a1f929c1b0f8f03131bc78c1fca9d3f", size = 18625, upload-time = "2026-06-14T03:48:49.221Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/65/33/7b83bde70eddaaaaef487751a9c3a5cc0c0be54620ded0e120ebdc401ff9/pyotp-2.10.0-py3-none-any.whl", hash = "sha256:1df2f6a1bcc3bb0716172a5215ddc2f8c7c7fd26a13df9927d52e1746934836c", size = 13768, upload-time = "2026-06-14T03:48:47.831Z" }, +] + +[[package]] +name = "pytest" +version = "9.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" }, +] + +[[package]] +name = "python-dotenv" +version = "1.2.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/74/26/2fbeedb218a787a5eea551c7532cac4e009f83d689dd2faa0d0353473f86/python_dotenv-1.2.4.tar.gz", hash = "sha256:f0d53e69935a851c0dcc78f3ab7aaccd8cabef0b92382b576b824212902873c0", size = 60824, upload-time = "2026-10-01T05:36:10Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/60/d1/38f3a3405989a89ac18390803e70c6ad7c7760da4f9b83cbeca0c44a0c72/python_dotenv-1.2.4-py3-none-any.whl", hash = "sha256:42269a8a5b3fd54ffa6f3d84b18abed50064717576b4ecf03dc4a55d8aa04fdc", size = 23266, upload-time = "2026-10-01T05:36:08.633Z" }, +] + +[[package]] +name = "python-multipart" +version = "0.0.32" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5b/42/55c32bb9b12693c092ad250a0e82edb5b31ddeda6eb772de5f308b3804ad/python_multipart-0.0.32.tar.gz", hash = "sha256:be54b7f3fa167bb83e4fcd936b887b708f4e57fe75911c02aebf53efaf8d938e", size = 46881, upload-time = "2026-06-04T16:18:58.647Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e1/04/e8135ebd1ad02c56ec633277529b2602ff99ff634be76cdba5744cf554fd/python_multipart-0.0.32-py3-none-any.whl", hash = "sha256:ff6d3f776f16878c894e52e107296ffc890e913c611b1a4ec6c44e2821fe2e23", size = 30042, upload-time = "2026-06-04T16:18:57.319Z" }, +] + +[[package]] +name = "pywin32" +version = "312" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/83/ff/32aa7d2ed0ab12b323aaa64f9b75e6ad4f8fd09f9ccfc28c79414d46838d/pywin32-312-cp312-cp312-win32.whl", hash = "sha256:dab4f65ac9c4e48400a2a0530c46c3c579cd5905ecd11b80692373915269208b", size = 6371877, upload-time = "2026-06-04T07:49:28.836Z" }, + { url = "https://files.pythonhosted.org/packages/03/d9/77040d3b43df3f3be32ea289433d660d2727f5ba327bc73be835127d9d60/pywin32-312-cp312-cp312-win_amd64.whl", hash = "sha256:b457f6d628a47e8a7346ce22acb7e1a46a4a78b52e1d17e1af56871bd19a93bc", size = 6914841, upload-time = "2026-06-04T07:49:31.85Z" }, + { url = "https://files.pythonhosted.org/packages/e3/cc/7b1ec671775756020a0ee7f4feeaf3c568f0ab86bd3900088cf986937a92/pywin32-312-cp312-cp312-win_arm64.whl", hash = "sha256:6017c58e12f6809fbb0555b75df144c2922a9ffd18e4b9b5afa863b6c1a9d950", size = 6727901, upload-time = "2026-06-04T07:49:34.244Z" }, + { url = "https://files.pythonhosted.org/packages/2d/41/12fbfd7f36ed2146d8bc9de96c2741296bf0d490b98508496cff322e274c/pywin32-312-cp313-cp313-win32.whl", hash = "sha256:7a27df850933d16a8eabfbaeb73d52b273e2da667f80d70b01a89d1f6828d02c", size = 6370184, upload-time = "2026-06-04T07:49:36.253Z" }, + { url = "https://files.pythonhosted.org/packages/ba/db/36a78e3403099d31d9746d13fdcde5accc43c1155f375a34d15983a479a7/pywin32-312-cp313-cp313-win_amd64.whl", hash = "sha256:c53e878d15a1c44788082bfe712a905433473aa38f86375b7cf8b45e3acbaaf9", size = 6914298, upload-time = "2026-06-04T07:49:38.876Z" }, + { url = "https://files.pythonhosted.org/packages/84/37/c1697194092b76de9ed47ca124323f02c57ffc8a45c06f88a3d5acaf01eb/pywin32-312-cp313-cp313-win_arm64.whl", hash = "sha256:59aba5d5940842075343a5ddc6b11f1cdf0d1567fe745290359dfbcc7c2eb831", size = 6727640, upload-time = "2026-06-04T07:49:41.083Z" }, + { url = "https://files.pythonhosted.org/packages/fc/2b/1f3cded5822fd49c02f40544cbb5f58c7cfd6b1694869fd476cb6170ee97/pywin32-312-cp314-cp314-win32.whl", hash = "sha256:a77a90fbb6881238d2ca9c6fd797b25817f3768fe78d214a90137ff055a75f5b", size = 6468928, upload-time = "2026-06-04T07:49:43.188Z" }, + { url = "https://files.pythonhosted.org/packages/21/82/3bf86d2e2808902013132e1ce905a7da0da53790f3836c64bf44d55e24f3/pywin32-312-cp314-cp314-win_amd64.whl", hash = "sha256:a4dd3a848290ef724347b19f301045831d8e802fa4464f491b98b1e0a081432e", size = 7024157, upload-time = "2026-06-04T07:49:45.34Z" }, + { url = "https://files.pythonhosted.org/packages/a4/0e/73f6d6800b4f27655abd9e9f6aaeaefcddb2b946e4674efa2bab184a7f7b/pywin32-312-cp314-cp314-win_arm64.whl", hash = "sha256:9fce94568364e0155e6dfb781ac5d95903be8baf28670632beab1b523f300daa", size = 6839598, upload-time = "2026-06-04T07:49:47.613Z" }, +] + +[[package]] +name = "pyyaml" +version = "6.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" }, + { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" }, + { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" }, + { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" }, + { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" }, + { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" }, + { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" }, + { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" }, + { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" }, + { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, + { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, + { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, + { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" }, + { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" }, + { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" }, + { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, + { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, + { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, + { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, + { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, + { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, + { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" }, + { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" }, + { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" }, + { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" }, + { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" }, + { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" }, + { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" }, + { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" }, + { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" }, + { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, + { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, +] + +[[package]] +name = "referencing" +version = "0.37.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "rpds-py" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/22/f5/df4e9027acead3ecc63e50fe1e36aca1523e1719559c499951bb4b53188f/referencing-0.37.0.tar.gz", hash = "sha256:44aefc3142c5b842538163acb373e24cce6632bd54bdb01b21ad5863489f50d8", size = 78036, upload-time = "2025-10-13T15:30:48.871Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/58/ca301544e1fa93ed4f80d724bf5b194f6e4b945841c5bfd555878eea9fcb/referencing-0.37.0-py3-none-any.whl", hash = "sha256:381329a9f99628c9069361716891d34ad94af76e461dcb0335825aecc7692231", size = 26766, upload-time = "2025-10-13T15:30:47.625Z" }, +] + +[[package]] +name = "regex" +version = "2026.9.29" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fc/f2/af1da9d3ceed77bfcdce40427d49ba0be94e4fe84245e3bfef68c10e75b6/regex-2026.9.29.tar.gz", hash = "sha256:8b5fcc4771732191b2b7d1dd68d8f0353f47f8d90b6150f6dce58bf1112442cb", size = 419199, upload-time = "2026-09-29T00:49:58.298Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/84/48/3fdcde9a0baa84d7d25571223265d6e434e114763b438601d54a8028bf3e/regex-2026.9.29-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:dc79d36d0618752265f0d575915bdc5c5130ecb9c9f6b3bcefeae32e4bdfafcf", size = 497903, upload-time = "2026-09-29T00:46:38.938Z" }, + { url = "https://files.pythonhosted.org/packages/2e/1c/4ee3e97c76f53940488dfe7a7e18705e78daac8cd7fb161d246b9e328449/regex-2026.9.29-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:3a21a9509d0ee88e7a70e1ad228cd2f0e0fd1e187458db132e8a8d18c97daf9d", size = 296416, upload-time = "2026-09-29T00:46:40.406Z" }, + { url = "https://files.pythonhosted.org/packages/37/14/f3f0ba083d2094392d5eabf56db5ea6ba469fd6e927afd187042054ea68a/regex-2026.9.29-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f57dc6b8fef170f105d2cf5cdce254f47b137d7755086cf7050f47e16582abba", size = 293633, upload-time = "2026-09-29T00:46:41.959Z" }, + { url = "https://files.pythonhosted.org/packages/c9/72/67e7a8ce17f1aea49df215564048efb49cc8c2b31a0e0fc30f36838f8516/regex-2026.9.29-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f93bc1c3486ef3747e07c9d7c1d0a147b8fbaab975f80e348aed6f71309dfaca", size = 805885, upload-time = "2026-09-29T00:46:43.373Z" }, + { url = "https://files.pythonhosted.org/packages/f6/78/25436bcfd4d2260b4b4090094d55d7ab53ec8a1ab4865a0b8bcb33c7d5c0/regex-2026.9.29-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9e1d3a4cb7993b708f0ada8d0c84590efd853f169e7147d2202c9da503180242", size = 878344, upload-time = "2026-09-29T00:46:45.328Z" }, + { url = "https://files.pythonhosted.org/packages/97/e6/a09ec3a23ae41d6179880e67f0aace9284b2d95f2d7b326eff203f8eec5e/regex-2026.9.29-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:dabee8f4935e731fb46b2a3091bdda0d3d94b3bbfb907d2b4f12eefce4009619", size = 919181, upload-time = "2026-09-29T00:46:47.041Z" }, + { url = "https://files.pythonhosted.org/packages/26/83/d2fbd2e4e3afb1167daa825187d196f313cbaa1a4768f311fb041bb0e3d2/regex-2026.9.29-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:39ab5894d971f9ac68baa6eca5c50387db579cfcacf36ae8df3feceb1815e6d0", size = 807783, upload-time = "2026-09-29T00:46:48.894Z" }, + { url = "https://files.pythonhosted.org/packages/46/0b/eb429a7016610d44fc89a597163f8c9127505f0d7dc724dc9effbb6a3ac0/regex-2026.9.29-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c1a9a6651197fbed6f0212591418b9def774fc3f8324f78d1bf0e6a63e5f8aa1", size = 783465, upload-time = "2026-09-29T00:46:50.64Z" }, + { url = "https://files.pythonhosted.org/packages/1b/07/58a3c0153c7476898430f6a7cf3d9062a1d17fbea4f43399ecaf411c7b4c/regex-2026.9.29-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:87fb80cbe3557e27e7b28b995c2b2eedf689b8886f941ab93e0e288f0976518a", size = 793519, upload-time = "2026-09-29T00:46:52.396Z" }, + { url = "https://files.pythonhosted.org/packages/2a/e8/161b94d39164520e21a7befe0245569bf7fda4c7cf1fc4e2df2b5def49da/regex-2026.9.29-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:3c5c2ef13797466aa64170cbb66ad98a32351dd4127694cea7199f80f213750d", size = 869293, upload-time = "2026-09-29T00:46:54.128Z" }, + { url = "https://files.pythonhosted.org/packages/8f/07/3b02ed829aa2decdc1955d222bd1e2f99d1c8bb4873bbb9a66b2f0a36bff/regex-2026.9.29-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:59b49507f47479e299a9e1bc41b5cb83a7afda0540625f1dbae886615978acbf", size = 770239, upload-time = "2026-09-29T00:46:56.106Z" }, + { url = "https://files.pythonhosted.org/packages/42/5b/ba61f6fe062eb8562e742367d177bb75370434138ef6c9d2a27114f8d613/regex-2026.9.29-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:0dd8af32e9f7b56b7f95cc1fd79b23054c3bdc172392ae560acc24d57b7ffe71", size = 861973, upload-time = "2026-09-29T00:46:57.665Z" }, + { url = "https://files.pythonhosted.org/packages/cc/27/767259b20e8a842948990f5e99138d6c077248fd42f8b5468b1d9ca4b814/regex-2026.9.29-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:db5e82ba15c142425b8406690032df89e39cca4a2e8afbbb9a3d84edc2373ac3", size = 796470, upload-time = "2026-09-29T00:46:59.236Z" }, + { url = "https://files.pythonhosted.org/packages/a0/05/2566c4ba849b68a8ab81a6bf428fa79d20aae7ddee83979103c0381df254/regex-2026.9.29-cp312-cp312-win32.whl", hash = "sha256:d0c3082bf79bcd6a614d55916590ad4b8f93200e10b97f463ea5d9d07c9b5f23", size = 269327, upload-time = "2026-09-29T00:47:01.135Z" }, + { url = "https://files.pythonhosted.org/packages/93/19/489bc8db91196381c935752df01ba3f607140daece33b78d88573f028e64/regex-2026.9.29-cp312-cp312-win_amd64.whl", hash = "sha256:fdd88ed5e20b1bcdd234421e454962c971aa44b653bdb7f1ea9ef683e90fb649", size = 280334, upload-time = "2026-09-29T00:47:04.436Z" }, + { url = "https://files.pythonhosted.org/packages/0b/47/fb88ba779d0e5e7d4b0ec1aceeb13845948a2cb876bd572a2d1dfdba090b/regex-2026.9.29-cp312-cp312-win_arm64.whl", hash = "sha256:4fe97894d1b306c919b4e50def1e6f6c522f4d03a7283811f4d108f1ce5d3ac2", size = 279614, upload-time = "2026-09-29T00:47:06.541Z" }, + { url = "https://files.pythonhosted.org/packages/79/d5/6080f7d1a6e7e36aa720f806ac93c035ba39c209ae6cc510e8ef4c0279c6/regex-2026.9.29-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:f1a0d5117230dd46b399a30a38afa44f79c99f3168988fdc4f425c3f928b39df", size = 497622, upload-time = "2026-09-29T00:47:08.251Z" }, + { url = "https://files.pythonhosted.org/packages/00/71/c87fc7a2e21a42f9d57489db32951c37eef56d153840459a80d464f0321d/regex-2026.9.29-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f0fe9834e5aeccaf19a0d8feb296d66a24be1a7c9922002f842a682cd5abb787", size = 296281, upload-time = "2026-09-29T00:47:09.764Z" }, + { url = "https://files.pythonhosted.org/packages/11/9e/aa0f4cde3bc4688c1d58b0cd8415edd708339bc0bc401a195b0b1e8c8f0c/regex-2026.9.29-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c90fcf7804ea0a54b896ce0f2b9565350220b8d4890fd0db461a476a4c687963", size = 293472, upload-time = "2026-09-29T00:47:11.723Z" }, + { url = "https://files.pythonhosted.org/packages/90/d4/e835c487850ed922a8d6074f953b888c8ea99775c76b9ed5f8a4d72eab92/regex-2026.9.29-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e11edba5bc344a32b029a7af9d4b3173982dd79eeafa0b9dbd787364414b0509", size = 805966, upload-time = "2026-09-29T00:47:13.235Z" }, + { url = "https://files.pythonhosted.org/packages/2c/57/ba8809847fbae8d2cbc71367c6ded510a7ec88bf52493c65efc1acf4effb/regex-2026.9.29-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:bb90e7177944b6684738c1fc36aabd2dd00d1de3be7dbe09f91e196f1bc0dc81", size = 878346, upload-time = "2026-09-29T00:47:14.877Z" }, + { url = "https://files.pythonhosted.org/packages/1a/52/e3da19fc3cc15ef67ab67e121e87887c3bccfdb683a7a9ec557c460ca5b7/regex-2026.9.29-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d06fcdecc10fc7954d7c8f27a03c96055fe525274dc84a7b0dbdc3d6b9e03dab", size = 919250, upload-time = "2026-09-29T00:47:16.622Z" }, + { url = "https://files.pythonhosted.org/packages/9a/8e/c1ed81f55f992f6aa0b699a592a50c1ce9e6d44ff1aee2c14c0537dcef9c/regex-2026.9.29-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d49c18f1ea294cf4adde2e5ac256e98c82ea9d708462ce4bf799dffa7cfe8a2c", size = 807902, upload-time = "2026-09-29T00:47:18.268Z" }, + { url = "https://files.pythonhosted.org/packages/ad/bc/5a6886eb470e41040e21e05b75024a18b6ebfe7ea400b72094a60f949101/regex-2026.9.29-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3e778bfccd63075167709136afbc251c1f683758d5bf49c803c60ac3f894ce6b", size = 783514, upload-time = "2026-09-29T00:47:19.921Z" }, + { url = "https://files.pythonhosted.org/packages/cb/52/6d951d453b023c6edb880f1ba474291b53b8ce1cc438b96a9db6d791d991/regex-2026.9.29-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:686ac5350fceae63830bb98805fcb8039325bf4c06d9f6f048ff65229d5bffa5", size = 793466, upload-time = "2026-09-29T00:47:21.552Z" }, + { url = "https://files.pythonhosted.org/packages/99/b9/d5a41adc08360f5eee0dc4846c578f002366947211fc8af5a69a64ee7b9f/regex-2026.9.29-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:26ec4ccce55aa533fbd603d08911b01101a8fcfec987845ac3ae2c7087b2bde3", size = 869464, upload-time = "2026-09-29T00:47:23.276Z" }, + { url = "https://files.pythonhosted.org/packages/4b/32/d76c9d91f5d798e2e9e67f6f85ec4ae35445ac425f7454797311cecb80ca/regex-2026.9.29-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:a655d34b2a6943af32401f3d94f72e9d731f6ad16285815550bf2b4ee69d420a", size = 770278, upload-time = "2026-09-29T00:47:25.193Z" }, + { url = "https://files.pythonhosted.org/packages/24/00/aeebdb540c620a0f7317f6d6fad80a47729ecf0599a24b5c34ec155351f5/regex-2026.9.29-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:0c992c19cd45058a4b92f68f139c93db168b48fb1f322c9a7cd620806afb6b51", size = 861949, upload-time = "2026-09-29T00:47:27.005Z" }, + { url = "https://files.pythonhosted.org/packages/12/62/d0314bcedfd3586197e4596931fa220260eb2385bf53184e5b9ae67db24b/regex-2026.9.29-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ebb8912f565b8cdbbf27debfe00df04202c20e2f651b9e32767930c5eace3621", size = 796602, upload-time = "2026-09-29T00:47:29.233Z" }, + { url = "https://files.pythonhosted.org/packages/ae/c7/d5a8c13a613facb03e0fb55c1ebaaf7bb35d8e2c1abe8bef8dca809fc1d9/regex-2026.9.29-cp313-cp313-win32.whl", hash = "sha256:4d7d93613b01b0199961330e49cfc52d479b3d5776c56c691db31130c0a07d91", size = 269306, upload-time = "2026-09-29T00:47:31.14Z" }, + { url = "https://files.pythonhosted.org/packages/80/a7/bf93a3a6afa5f7bc16b7afb94ae581b01cae620b8ad56bd8f9572a985959/regex-2026.9.29-cp313-cp313-win_amd64.whl", hash = "sha256:61956f074ecd123f55adca68ee3eab46e6a07ad3f8e64e6db95dfacb444f55c4", size = 280307, upload-time = "2026-09-29T00:47:32.709Z" }, + { url = "https://files.pythonhosted.org/packages/b2/7d/388274e53605a86297f433a08102a7bbdcf9379d47683d307ccaefd88e2c/regex-2026.9.29-cp313-cp313-win_arm64.whl", hash = "sha256:bfc71e6d970419c1309b3640305298643e2a734cad3f7cfb6d2ddee4175ab53d", size = 279599, upload-time = "2026-09-29T00:47:34.674Z" }, + { url = "https://files.pythonhosted.org/packages/93/1f/d9dc6f02f569625faf67a4daec926cd5023472dcd69bb44286dccd5a5ab3/regex-2026.9.29-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:957bb708e8057ab1649ba566456429d691ec9b90d1c9ad1af1ba7ffbbeaf05f2", size = 497598, upload-time = "2026-09-29T00:47:36.541Z" }, + { url = "https://files.pythonhosted.org/packages/9c/83/9b693a3fd1451381e812031a8961ec5b3b8f0c8cc6871f14c5223642804d/regex-2026.9.29-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:c9b602fae1e00b7c035d661ce85575365719192a7b46784bd71cf64c68053aa0", size = 296250, upload-time = "2026-09-29T00:47:38.233Z" }, + { url = "https://files.pythonhosted.org/packages/dd/5f/52bc2abc3fef040cd9de76ab29c918d6a717a454ae2b9dd7938b0c95656d/regex-2026.9.29-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0166844493626c5015c6088ee15c9ca2fd060ca15b7641d1657da6a58432ae33", size = 293518, upload-time = "2026-09-29T00:47:39.957Z" }, + { url = "https://files.pythonhosted.org/packages/dc/fc/cf50671215ee0057046980b4571ef8646a005819bb67f0957e779ed107a5/regex-2026.9.29-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b97a38fb4c732b6832db6bf108963adbcd82ef1268ba2025dce390f45af75efa", size = 806037, upload-time = "2026-09-29T00:47:41.676Z" }, + { url = "https://files.pythonhosted.org/packages/14/4b/dddef8fc15c63e4347cc9efb138d0cd306f30e6c98acbcc81a8f780083b9/regex-2026.9.29-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a540abfab208e1b7ef2df231c40ef3b6cbb30a0aad6204e9b6a81c10a6794628", size = 878881, upload-time = "2026-09-29T00:47:43.755Z" }, + { url = "https://files.pythonhosted.org/packages/9f/cb/38daabed32d28f7e58a06e9344ce00dc67952e9996bc578ed6a29fe1240e/regex-2026.9.29-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ddfa987262763c3c22a8367d2a49c244b018a74c3a8e3ab1a864119ad45c5633", size = 918684, upload-time = "2026-09-29T00:47:45.594Z" }, + { url = "https://files.pythonhosted.org/packages/a9/4d/041d9458a645fee4fce4d642a89d27271a3cfcd91095104f6dde44da70bf/regex-2026.9.29-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2f7f7aa47b229f2b39a2ae2596d2ad5625d77b5eb9856fac2dab3eb506cdd0a0", size = 807176, upload-time = "2026-09-29T00:47:47.372Z" }, + { url = "https://files.pythonhosted.org/packages/bf/c4/4383eed7aa5aef67616cb1b3f3ad06b7c624c4e6cced48630cd5ce133d85/regex-2026.9.29-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d9b77b25b4f395f92de6099ab08e8ae2bc7e51dfe157f22900902243a5cc90c7", size = 784315, upload-time = "2026-09-29T00:47:49.518Z" }, + { url = "https://files.pythonhosted.org/packages/5c/a6/0086ad31cebb183c637d3198547075aa493afde308e1ff61fccccb29ba6e/regex-2026.9.29-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:34b6925af9853bf461950e6508910f179fd6e9b1a7ec8548e069606b7e51a26b", size = 793748, upload-time = "2026-09-29T00:47:51.279Z" }, + { url = "https://files.pythonhosted.org/packages/d5/a0/f9005cba3f629a859573fc5d1224ea4e1f97919ec8581d018e03a351a604/regex-2026.9.29-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:addd736a0547d553283adaf4e05d7104e7f2c7b0b092e9b4d28756825f14531f", size = 870302, upload-time = "2026-09-29T00:47:53.368Z" }, + { url = "https://files.pythonhosted.org/packages/01/4f/e1a3e46bb5315a4e18b01a990e7a28e2a16595609d50c442baf2815a3c65/regex-2026.9.29-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:fe3fa1dd453ed5c7f5ea23a26218329790ed7197a99b90e94330e313959a7f52", size = 770299, upload-time = "2026-09-29T00:47:55.606Z" }, + { url = "https://files.pythonhosted.org/packages/2c/fe/f303b4acfda44e1ff1379368748c1ef2dad04a6a8e9c0ecbc970b19d97ca/regex-2026.9.29-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:0cc63b5e47c12a48d90c7e9d7de6a035dd14f62868aaedbb4e0ff8ba2b8bfe7b", size = 861570, upload-time = "2026-09-29T00:47:57.617Z" }, + { url = "https://files.pythonhosted.org/packages/60/b6/b4f7e99249f596017c60ccad5faf9310fc8e3e59bb2244940a90a1b0bdff/regex-2026.9.29-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:724184b4aafed865e4f13ca313fdcb43024300c028ec67319cfa16847d84685e", size = 795967, upload-time = "2026-09-29T00:47:59.922Z" }, + { url = "https://files.pythonhosted.org/packages/fb/d3/fc865a4638d9f6762192b6bab5b7aa1f33a90e9e99578c2e111e2a63c8c3/regex-2026.9.29-cp314-cp314-win32.whl", hash = "sha256:c6c8fabf1dafc1f1ddcbb67896d3f93efb092e8c4b6322d7389b944e76a484e5", size = 274758, upload-time = "2026-09-29T00:48:01.8Z" }, + { url = "https://files.pythonhosted.org/packages/31/e2/c2b466924ccbeb874862968ca638051b15a8fd29d994a0e99004a5cbf78e/regex-2026.9.29-cp314-cp314-win_amd64.whl", hash = "sha256:1c2a0026062abcc321a53db4a185ceba0b59a66b5d37b0808917a88b55a5257f", size = 283817, upload-time = "2026-09-29T00:48:03.614Z" }, + { url = "https://files.pythonhosted.org/packages/c6/42/ea0f8dbaa924fa75c6338935eaee2f44dab369b27f02db1e03d74344b049/regex-2026.9.29-cp314-cp314-win_arm64.whl", hash = "sha256:121a76a0985db80ceae9e171c337f8c927868e37d01b54e3ce87bc87f9c6a208", size = 283663, upload-time = "2026-09-29T00:48:05.624Z" }, + { url = "https://files.pythonhosted.org/packages/44/48/d58e5081119f5c223bbb37d2340acde3d069e1df8e8cd166c37502eee4da/regex-2026.9.29-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:e31f72490b7c12f7790e1e25c3afffd20503ee1bfb43461d7838b871ff244b19", size = 501393, upload-time = "2026-09-29T00:48:07.833Z" }, + { url = "https://files.pythonhosted.org/packages/72/3c/c49945287d4f9efee7d41f98072f8ad880efb8f430595a612fbdea996a4e/regex-2026.9.29-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:80ea96f5c1a30bf09007d48466521d9c294bebe197c708c3359096e3e3691632", size = 298237, upload-time = "2026-09-29T00:48:09.684Z" }, + { url = "https://files.pythonhosted.org/packages/f9/1f/688cb61c3d4cf7bcc1ed444b5cc49399eba3e51c469ae285cf87fea3022e/regex-2026.9.29-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:554bffadcbcb6d5f4e5fb10a61cc52084b9a63d1dab5f10bcd2c4343972e8e2c", size = 295936, upload-time = "2026-09-29T00:48:11.454Z" }, + { url = "https://files.pythonhosted.org/packages/26/a3/de43ac6b877b7d09c19a3a426b1bd5acdd209eaaf68f406466f80439ccf6/regex-2026.9.29-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:864e9b87ac33c3fb9fb4ad48166d4fdb579c351d5c77deb0d34bccb36a775cd9", size = 816905, upload-time = "2026-09-29T00:48:13.321Z" }, + { url = "https://files.pythonhosted.org/packages/62/14/9940763201c51d537786304984c67d0fc3d2ed18837ffb6f09a869f6b6c9/regex-2026.9.29-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:044265d77d94f5e3cb2fd72c76723807c429cb8c533e9d4672d0334a6f14f588", size = 881527, upload-time = "2026-09-29T00:48:15.313Z" }, + { url = "https://files.pythonhosted.org/packages/d3/e1/c842d8df0b23245ebf202f8ab9c39fd48e2db39959454ec39a41c8c72082/regex-2026.9.29-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2089fe39c406784d90101c726755ffa1497bb74638fd434300d2b88006186de8", size = 923115, upload-time = "2026-09-29T00:48:17.328Z" }, + { url = "https://files.pythonhosted.org/packages/d8/c1/98622479e3c354a446a75232e522d747d2b3df23092dcd8a5309380a2020/regex-2026.9.29-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0def9fb6abac55492d6d51cddb7225d07d6f279e774e0adc08569a54a5fc8d46", size = 820674, upload-time = "2026-09-29T00:48:19.32Z" }, + { url = "https://files.pythonhosted.org/packages/6c/d0/5808c95f9c79ed27b5eedaafc3df6239ec56a49f2e23ea8f831b18427c82/regex-2026.9.29-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:888d60953908dcf761aa320c3e390ab8556efbdb551ace63921de90f6ae0848d", size = 793306, upload-time = "2026-09-29T00:48:21.615Z" }, + { url = "https://files.pythonhosted.org/packages/bf/d3/021ca2638671ad20603bcd9b4d5bfa35d2610cd216a043ea7f0b44ea39f6/regex-2026.9.29-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:ed511a0708e2297e1d6431e7fb217e3402791e491e02da800658ace4973df1bb", size = 803103, upload-time = "2026-09-29T00:48:23.871Z" }, + { url = "https://files.pythonhosted.org/packages/6b/2d/755c6d13ef9c657378013676c391c7a402166b3f419a464a3e058dcbe533/regex-2026.9.29-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:e1172147d28d8fbcf8cb8d26c41506169f5ad8fe9ec969cb116835a19d4d8eca", size = 872175, upload-time = "2026-09-29T00:48:26.255Z" }, + { url = "https://files.pythonhosted.org/packages/6c/fc/e1cab183b9dafe8597f58c1c766da9bf96204d3b2f232bcf3eeb75ff7b6c/regex-2026.9.29-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:92f05c9c42bde5785dc48770bc2194d9f7442544156f951e19cd31b096cec562", size = 777362, upload-time = "2026-09-29T00:48:28.389Z" }, + { url = "https://files.pythonhosted.org/packages/06/7c/e10ea17fba31fb4a1f9d13ed53a2d2a9066a2aea58d7557e263f6d99e7b0/regex-2026.9.29-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:f37964e4a5e993d2fd45147741e9dff7f34a2d8c00ab94c4ea0514a4677f959e", size = 865606, upload-time = "2026-09-29T00:48:30.4Z" }, + { url = "https://files.pythonhosted.org/packages/8e/6e/69824d9aee1fd41c54ea7264654a47c8d9d84d8a228e11c2bcf4c201ed81/regex-2026.9.29-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:951733b1bbdb71e377cec567b409f1a7881b47cfcad84121aa74cb575fa425ea", size = 807945, upload-time = "2026-09-29T00:48:32.375Z" }, + { url = "https://files.pythonhosted.org/packages/89/22/857050a86e21ce60193e02a8ef662521f2e263a645c8b1b905fc136b61a7/regex-2026.9.29-cp314-cp314t-win32.whl", hash = "sha256:65b408d8fcb273e3499e7ef2ce796810da1becd208c7fb4373692a242d79d461", size = 276758, upload-time = "2026-09-29T00:48:34.72Z" }, + { url = "https://files.pythonhosted.org/packages/4d/96/56808fe029553d7d4c703414f2a527faad2ea2bfa9ca094a2e7f8762b530/regex-2026.9.29-cp314-cp314t-win_amd64.whl", hash = "sha256:bf48516e35cf848390ea68850aba53e7c333720d2945b4d2c25b69fc5171723f", size = 286527, upload-time = "2026-09-29T00:48:36.864Z" }, + { url = "https://files.pythonhosted.org/packages/01/aa/074e2cfb3d8101a6a764aba5f7c5d1e21de087483e35bdc0c4ce2eb60364/regex-2026.9.29-cp314-cp314t-win_arm64.whl", hash = "sha256:9173db3be74a35cb6731701094b98120f7ee4876a287882a59cdea1fa7da342f", size = 285954, upload-time = "2026-09-29T00:48:38.901Z" }, +] + +[[package]] +name = "rich" +version = "15.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c0/8f/0722ca900cc807c13a6a0c696dacf35430f72e0ec571c4275d2371fca3e9/rich-15.0.0.tar.gz", hash = "sha256:edd07a4824c6b40189fb7ac9bc4c52536e9780fbbfbddf6f1e2502c31b068c36", size = 230680, upload-time = "2026-04-12T08:24:00.75Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/82/3b/64d4899d73f91ba49a8c18a8ff3f0ea8f1c1d75481760df8c68ef5235bf5/rich-15.0.0-py3-none-any.whl", hash = "sha256:33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb", size = 310654, upload-time = "2026-04-12T08:24:02.83Z" }, +] + +[[package]] +name = "rpds-py" +version = "2026.9.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/42/68/3bd46b8a5e01d3c2ebdf9c5e9497912e3fe0cde02bac21a7130ca866e403/rpds_py-2026.9.1.tar.gz", hash = "sha256:4793ef7f78268b124b73fa933440f01d258bbae01de9fa53e9080c9ab0425a12", size = 63948, upload-time = "2026-10-04T16:32:36.469Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5d/34/a828586ea3329fbb50895b50e9cf98ca3d924a9f41a0b26d443bff1b3794/rpds_py-2026.9.1-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:50906f5aea24b5a865cbd0a589698288631d9f3a54c3a937c83aefa95a0d14af", size = 346822, upload-time = "2026-10-04T16:29:16.258Z" }, + { url = "https://files.pythonhosted.org/packages/90/81/ac6a0d064982251856ce009c9e1dd51a34110b3c055aa7d1aad18b4899a3/rpds_py-2026.9.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:e21c1429e205828ea886a2293a4a2c8e01f4c25d9893ca330e97a6cf73f52e7b", size = 341380, upload-time = "2026-10-04T16:29:17.706Z" }, + { url = "https://files.pythonhosted.org/packages/42/ff/bf7d54f362748fd6a49277b9d6531c394074110fedf50ad791ec59133fbb/rpds_py-2026.9.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2693b2728bbcc48d09a981a356954b0c47c53ff25b545856f28a889ea619f69a", size = 373053, upload-time = "2026-10-04T16:29:19.063Z" }, + { url = "https://files.pythonhosted.org/packages/60/de/74b0ccdbbd28687b9b5fdb34c1cabed88352182facaced9d6f5486b8b9ed/rpds_py-2026.9.1-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8601470267d938bcb7f3ab1a336100af51a4fd5b6ed030ef52461bb3ef5e7e07", size = 377742, upload-time = "2026-10-04T16:29:20.543Z" }, + { url = "https://files.pythonhosted.org/packages/c2/6d/b979775a3057b2a5c26d75ecbff60a20a24ef081db6dd76836fca3f20247/rpds_py-2026.9.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3890a6aa36e6baa53d5258a2a25d3ef8b37ad165a6ab27a892d7c3e3a432cd69", size = 487289, upload-time = "2026-10-04T16:29:21.958Z" }, + { url = "https://files.pythonhosted.org/packages/1d/5d/7c34734ce3ece943d9d6ee0122a74a21bbc75b47acb5b7053c83f6efb1ed/rpds_py-2026.9.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6b5b393eda5ea42cca1c1a6665f2a4882b4fd5d1777e41ce0545a107fb008c9d", size = 390710, upload-time = "2026-10-04T16:29:23.378Z" }, + { url = "https://files.pythonhosted.org/packages/1d/6d/b26eb1e75395925b3a142ed351cbed2ec8212f5c9d937aee3df32c701baa/rpds_py-2026.9.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:addeda51556dac7c1a2f14cda62db8b621cd12afba3091d03a96c72932387eab", size = 371261, upload-time = "2026-10-04T16:29:25.1Z" }, + { url = "https://files.pythonhosted.org/packages/7c/98/b2fdfe10301a9e27af21e634337fbba7baac0bc17fe44619a17c26bba2cb/rpds_py-2026.9.1-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:d9edf30457d74eebfd76b045535e36f1cd89062566a128a0db2145ca042d787e", size = 376075, upload-time = "2026-10-04T16:29:26.748Z" }, + { url = "https://files.pythonhosted.org/packages/c5/b1/c4b8d954e49c3c69cf8063c0c9e0919c99f3d2cecc46311606a1cbe835dc/rpds_py-2026.9.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:815d26356930846a40c7bc1366e7b1b0320ab8a063e66c11298a208bed0fd237", size = 399358, upload-time = "2026-10-04T16:29:28.282Z" }, + { url = "https://files.pythonhosted.org/packages/b3/59/9559a7293c97dff0cbb293efffbd36644103883ecc27741af8ef90c84ca8/rpds_py-2026.9.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3b5a6f40f0a1486b4b36c888123afc67acdbd9f33235927acf5ff295429a0ba3", size = 550267, upload-time = "2026-10-04T16:29:29.851Z" }, + { url = "https://files.pythonhosted.org/packages/6c/9d/6dc60e49511de4c8b00e74f9c1d43aed4d27f712cdb1ade92f2c199cbf64/rpds_py-2026.9.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:b5b8b0753718d258fd454283fbd57e14545d3b40583fa672e27cb4f987626bcc", size = 614018, upload-time = "2026-10-04T16:29:31.54Z" }, + { url = "https://files.pythonhosted.org/packages/fb/bc/92a4ecf27301b886232a413360f6b348a81d55ffef654f2ebda5970ebc3d/rpds_py-2026.9.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:46d80bc76b51a6c24f9944368c28d38b8bcbcea1da4f2f8d3ebc31a67e8c6ec6", size = 578509, upload-time = "2026-10-04T16:29:33.01Z" }, + { url = "https://files.pythonhosted.org/packages/08/53/efb97f2e6589b7ab8394385a73870e0b2ab2a195281c117b21fd6d7d4855/rpds_py-2026.9.1-cp312-cp312-win32.whl", hash = "sha256:befc2d6a953e563f8a7bfd87a42c22ebf8a3e980dcb7b6a4d17b70b0e914e8a3", size = 205401, upload-time = "2026-10-04T16:29:34.451Z" }, + { url = "https://files.pythonhosted.org/packages/1c/84/1700cc748d0eaa747486e4e82882d2575224e6e9a3148df583058850c629/rpds_py-2026.9.1-cp312-cp312-win_amd64.whl", hash = "sha256:5ce8943f79c2210f7abcc28e86367b03b28d95027fd01c46d2472373ae70c86f", size = 222923, upload-time = "2026-10-04T16:29:35.709Z" }, + { url = "https://files.pythonhosted.org/packages/c3/89/0302215373c2f8b4408b0fdfee955368cc378a98ff59508d47a7e0dc2dbf/rpds_py-2026.9.1-cp312-cp312-win_arm64.whl", hash = "sha256:501909f2e4a1e2dee528ef766fe3c469060ebc17e54a8383d404ba07a81a6f02", size = 218415, upload-time = "2026-10-04T16:29:37.263Z" }, + { url = "https://files.pythonhosted.org/packages/83/ea/ee88fd9e756ff93fb6b1182a47ec09504a242620e33ce1d20679efefe841/rpds_py-2026.9.1-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:a36b70596407634ca82d4b989a3729074a008537a0522e4c8046a67c729103e9", size = 346229, upload-time = "2026-10-04T16:29:38.82Z" }, + { url = "https://files.pythonhosted.org/packages/57/71/a097d6552f837500fc36e6b23d09cfb9890c3cc47531f9ca64e149799615/rpds_py-2026.9.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:eba5d173f7d5708b22a93815017a4611873ed54db9f268077c0dd1ed99cfc858", size = 340514, upload-time = "2026-10-04T16:29:40.405Z" }, + { url = "https://files.pythonhosted.org/packages/bd/b7/497e85768bf4e0d8ddbaa096a4cac31d1509251dee2728a8490aa367e0b5/rpds_py-2026.9.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:457866b85daf5034296666168b84a69e0b2e89dc4f1af102b46f6448a60b9063", size = 372611, upload-time = "2026-10-04T16:29:41.778Z" }, + { url = "https://files.pythonhosted.org/packages/52/4b/74ab4108916250b6e198e0d3af05bc6835eb046315f22f7a0ceb49667c5a/rpds_py-2026.9.1-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a3a52a3ba86436ab3aef510fbe21512abc2ddd1993005dfe50514bd2284ef025", size = 377712, upload-time = "2026-10-04T16:29:43.242Z" }, + { url = "https://files.pythonhosted.org/packages/0c/8e/067e77d9d7b3cc793c9d909b7e97e7aadbbb1fb094093b6876902cc96d38/rpds_py-2026.9.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d7841166b7fa64c9c56404617ae4341448847482d45933b13135d26c130519e5", size = 488106, upload-time = "2026-10-04T16:29:44.692Z" }, + { url = "https://files.pythonhosted.org/packages/3c/b4/c5aae6c2dde269bf955f6b7d35065c655a57e47750d9668052ad67e74dda/rpds_py-2026.9.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:926bdd3e3b5998ddf70cc64bc8cf57209571f9044542913afb673799fec77dd0", size = 390795, upload-time = "2026-10-04T16:29:46.129Z" }, + { url = "https://files.pythonhosted.org/packages/a0/36/76fab39973ee11e7f9f357c55138197bb01c86f6502cb76487e3b4f42db0/rpds_py-2026.9.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7868b85224291c6cb6759f9b5adb9745f486d226f62b16a614dd5a2a5ab2b35b", size = 370794, upload-time = "2026-10-04T16:29:47.603Z" }, + { url = "https://files.pythonhosted.org/packages/3d/fe/cd2a80e6d7b871937a60e935c5d507aa390d143f4ff3636f640b9733d5df/rpds_py-2026.9.1-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:3cd182d7291d29b92c521a0069d9c01ba6193628a9a105531d11b40a6d731a33", size = 375673, upload-time = "2026-10-04T16:29:49.223Z" }, + { url = "https://files.pythonhosted.org/packages/6c/18/7464a9953724e55a3b3206062fa0ffdeaa519584c6aabf65d3956d94f131/rpds_py-2026.9.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:e6ea1cda8d8c688278430e4268a42f5e5da3bdd74578dfadc0820c3f1766ce83", size = 398758, upload-time = "2026-10-04T16:29:50.601Z" }, + { url = "https://files.pythonhosted.org/packages/c0/86/1534b436700fd49ff411063b7c4d7e938adfabf90895b6cf1622d5a7d1f6/rpds_py-2026.9.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5943980471829f6de242a20b109de3111ba6b77e3af0ffc587028ac854b05e6c", size = 550370, upload-time = "2026-10-04T16:29:52.002Z" }, + { url = "https://files.pythonhosted.org/packages/57/1c/e1fa82a8a01e3c5820f3ba98a8b2673f642128eb368fa88871b01dd2c909/rpds_py-2026.9.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:76d3af9732d2dab69f28179b40ba2d87e2f1d5824b4a694780aa787d685e8f36", size = 613106, upload-time = "2026-10-04T16:29:53.655Z" }, + { url = "https://files.pythonhosted.org/packages/29/55/b20b8c4c3dde8755bfcd5b08492a02d0cd2e26929aedf6199ca2a377d42a/rpds_py-2026.9.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:78326f4cb4427a56ba4996c0762b63be45f06b85f086526420d2b3a66e40f84d", size = 578235, upload-time = "2026-10-04T16:29:55.157Z" }, + { url = "https://files.pythonhosted.org/packages/55/42/df3f7bbc3f7ab37a8a9db8d6c2ff2c985422899f1f7926afbf7ec3c0b8b4/rpds_py-2026.9.1-cp313-cp313-win32.whl", hash = "sha256:172e47169583f46ce118cbec68e6795d0da0f4606b488b6434f8276bca0a058c", size = 205293, upload-time = "2026-10-04T16:29:56.669Z" }, + { url = "https://files.pythonhosted.org/packages/31/9c/ba5a9569d719bfdd6ce863df4133ac6a1658cf1b07cc3534c31db729fbbc/rpds_py-2026.9.1-cp313-cp313-win_amd64.whl", hash = "sha256:3e93b2cd69a9830be33e03945cd7cda940a0a8bfcfbff41d6144f0cb0d3d8bd9", size = 222409, upload-time = "2026-10-04T16:29:58.049Z" }, + { url = "https://files.pythonhosted.org/packages/35/72/f28ca566f6c23c35bbf7445f65eb0364577b25a026305995e24f78b83d94/rpds_py-2026.9.1-cp313-cp313-win_arm64.whl", hash = "sha256:d151e148117294133bf8af7eeace085e7e87432db15ab6adf640330298a47f6f", size = 217681, upload-time = "2026-10-04T16:29:59.449Z" }, + { url = "https://files.pythonhosted.org/packages/8a/f2/67b94be1532767803415c1c5a1fd88ea487643d74a673cec1ba140af77bb/rpds_py-2026.9.1-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:c9d1aca01f49170fdcf5c92761b1fafe97f554b721ca4570c5949fff778f0d4b", size = 347178, upload-time = "2026-10-04T16:30:00.865Z" }, + { url = "https://files.pythonhosted.org/packages/04/37/b751de2b59b0197a1d92a5dd491de88e8a5e928c2e6562581974f1e85263/rpds_py-2026.9.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:3f0e9ac28fc067d4d34b88ae43c48e9489455c97fee9633d851f7eeed5a05d35", size = 341878, upload-time = "2026-10-04T16:30:02.564Z" }, + { url = "https://files.pythonhosted.org/packages/72/e2/5873bc4643c250db9e05d48dc0c93763d4aa68bc3b81164cb1af3b45b284/rpds_py-2026.9.1-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:07deecbfce94c78473018bc7d10b337cc651d12df87a1eb2cb3e4024bc9c33d0", size = 373963, upload-time = "2026-10-04T16:30:04.026Z" }, + { url = "https://files.pythonhosted.org/packages/51/03/5acf7632158247f3f6386ff0af3a1ee48167d575037e8d0920594b76d92b/rpds_py-2026.9.1-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:821b2755db9194409254012f429c56643416fb96ef9be090be82ec8826b7f477", size = 377975, upload-time = "2026-10-04T16:30:05.555Z" }, + { url = "https://files.pythonhosted.org/packages/e6/00/63fda451b8bffa5808fc8bb311ee7c073b340974b09f273c7b2a145d3d62/rpds_py-2026.9.1-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3c91c210ae7645626c608400e3519b4a642f837cce09ca830db3beb2e9f274d4", size = 490347, upload-time = "2026-10-04T16:30:07.156Z" }, + { url = "https://files.pythonhosted.org/packages/a1/ae/c093ffd070ba0fb02f76c565d06fecc65ad6e4afdbae78f7031076d3cdac/rpds_py-2026.9.1-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:54ac2158a6f96cfbabff0b2eedaf94b90c5ec7ca8317fcadc61e1c2b2e0ff6ef", size = 392962, upload-time = "2026-10-04T16:30:08.77Z" }, + { url = "https://files.pythonhosted.org/packages/22/9d/d08a1128ab199b2f0cf25bfeb0639bd05119fff4b7c47bec24ef9a8ec23f/rpds_py-2026.9.1-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eac2f5dbafd585dfe31f86a23ebf0d3ba480a9d49ebc87947267b5608d4ea0cd", size = 371623, upload-time = "2026-10-04T16:30:10.501Z" }, + { url = "https://files.pythonhosted.org/packages/53/c7/4758ddcbb75609414bbccfcb11d612436f9b3ee821bd2f33f0f1604ee648/rpds_py-2026.9.1-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:8aa5dda18d39b6143eb24809d158f9252c88f402749b6f1b62a506cc7d96cc35", size = 376997, upload-time = "2026-10-04T16:30:12.124Z" }, + { url = "https://files.pythonhosted.org/packages/87/e4/947bd7f608ff60faf46dc9d389c3dffd0e3d767d78a0be19978448ef0ce7/rpds_py-2026.9.1-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5c90e7fa02e8f5de0d10c17595c568ada48c5302e749462c0ea1a4c362111a86", size = 400169, upload-time = "2026-10-04T16:30:13.804Z" }, + { url = "https://files.pythonhosted.org/packages/4b/35/fe93e020a0543b5670472c18d7e6af3197c08da571240ce1965c84f85c0f/rpds_py-2026.9.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e6d198bad4e49dd6732fbd636e2fc5c082f45c8cad0b4acb756b00c82c76072e", size = 551615, upload-time = "2026-10-04T16:30:15.332Z" }, + { url = "https://files.pythonhosted.org/packages/0d/4f/5d2a0136bb03b2a56a39dc6ff92d58a6e3e53a2e17079238b86228882f16/rpds_py-2026.9.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:96beca19ec79de272e8668585380ff9092c47077c1d7a1e098e00bbd921f4785", size = 614655, upload-time = "2026-10-04T16:30:16.92Z" }, + { url = "https://files.pythonhosted.org/packages/09/1c/3f1025aaf70d9bf7272cc41f8b64ee76b48bf01726248138430e16f23b38/rpds_py-2026.9.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a5cf77eb04f20b720be95265a3e00eb2a14814074255cc27069c551b2db53118", size = 579602, upload-time = "2026-10-04T16:30:18.555Z" }, + { url = "https://files.pythonhosted.org/packages/53/0d/5c72e6204f76610608706da32b6b7e11ef7e10317558a7bb15a122e008dc/rpds_py-2026.9.1-cp314-cp314-win32.whl", hash = "sha256:a03d57b86d2a51d0a66c92177e2be154ad015f357791d306e714569999cdb4cc", size = 206315, upload-time = "2026-10-04T16:30:20.05Z" }, + { url = "https://files.pythonhosted.org/packages/a4/0b/489d48abbcc7d70cf3fbf662d9d22abf1f4650761c0a9ae05260800800d3/rpds_py-2026.9.1-cp314-cp314-win_amd64.whl", hash = "sha256:837c6b305e26fe0f75b15c92cf3b2ba29e0ae19dc40b1c557b026cb426347d0c", size = 222852, upload-time = "2026-10-04T16:30:21.604Z" }, + { url = "https://files.pythonhosted.org/packages/91/16/bbb05a7e6a10cf79ba639be7f799d770ee15f64175cc61d081b218dd402a/rpds_py-2026.9.1-cp314-cp314-win_arm64.whl", hash = "sha256:fce4b85234a0cbad67bf8e6e1201ee815d172c9aebad75f25645bc4d834f8e31", size = 218709, upload-time = "2026-10-04T16:30:23.036Z" }, + { url = "https://files.pythonhosted.org/packages/22/ac/ac507a0a4ec478ca470440a09583db4be5259ba7670aeba0620822f1e57a/rpds_py-2026.9.1-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:3a72c11530d71abfb66c8d7696a2f86c43e63fca8b948f1a784ac490f4ec688e", size = 349596, upload-time = "2026-10-04T16:30:24.558Z" }, + { url = "https://files.pythonhosted.org/packages/e9/f2/817a46b658d5070f477f722c298ee9a24525b0e4017347964146ef5fdd0e/rpds_py-2026.9.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:068c37bba854ec2fe42f7365c640af11dd9895890ccbf2df5070d0c059bd7f96", size = 343114, upload-time = "2026-10-04T16:30:26.048Z" }, + { url = "https://files.pythonhosted.org/packages/6c/42/6ade976b13ac1b4cb3bf2eb603f1be2fe74df19a29988d4c2b386be59d6f/rpds_py-2026.9.1-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d7fca4eb6df565e2a928f1c7dad92d27db8f9df0f449e76423ed5d7e713ed445", size = 374197, upload-time = "2026-10-04T16:30:27.699Z" }, + { url = "https://files.pythonhosted.org/packages/d9/70/77cdf1d3f1a07faabe936016ae623aec7981f73108a8fe7a203ed2e21998/rpds_py-2026.9.1-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c933c6678c6f116ff8af47a4c6db0868b8ace74af0343016c0ef00f00272ea69", size = 378025, upload-time = "2026-10-04T16:30:29.451Z" }, + { url = "https://files.pythonhosted.org/packages/3f/6b/18a44a3beaa9b7931acb04af7bd9539836477c630a794452d4826d6185d4/rpds_py-2026.9.1-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:028ad274ea951dac64491b5d1e65712a4aeabfdbdb9fccf797b57bd899b0c495", size = 491969, upload-time = "2026-10-04T16:30:30.995Z" }, + { url = "https://files.pythonhosted.org/packages/73/27/fb39cfd6bddaf741b024f813890374578ff8ac1f1adc473659c048b03b05/rpds_py-2026.9.1-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:740d0a99cf9de0b17a3943388e9294a59becf75e7c43421f387bd3c7a9901f7c", size = 393905, upload-time = "2026-10-04T16:30:32.628Z" }, + { url = "https://files.pythonhosted.org/packages/ed/71/0fa7bb77b57af0d710273964180d11b503f62b8a5c358eb2d8c3f62feff6/rpds_py-2026.9.1-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0da298fb372dc192610a4b9ecbc68a0cd8b675bbbd1fc519d01b41cfd658333e", size = 374569, upload-time = "2026-10-04T16:30:34.257Z" }, + { url = "https://files.pythonhosted.org/packages/54/22/f41cfac269af3b449513ef1bc3d7f32fde52abbbd2d01c7e76b47743acd9/rpds_py-2026.9.1-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:eb61be926bb81567c1f48bdc8aa22b9855048dc2efd53871f9f7e6e9a5632346", size = 377482, upload-time = "2026-10-04T16:30:35.997Z" }, + { url = "https://files.pythonhosted.org/packages/b4/fc/312b49006e7f8f9ca5f96647577b8aa6f3df30519c46bd448f5c425af0b2/rpds_py-2026.9.1-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:42e75466f83cd43f6026c81eab74246efb2bdadafb307b85700632d06c68f299", size = 400040, upload-time = "2026-10-04T16:30:37.76Z" }, + { url = "https://files.pythonhosted.org/packages/cf/a6/18cca7a878dc7fa95165a83343fd4d7b65643fd22e54e47340a451121d5c/rpds_py-2026.9.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:617f59cde379b4f648a09797b7f683d04b90a46344cddab85639da5aff0f5531", size = 551849, upload-time = "2026-10-04T16:30:39.443Z" }, + { url = "https://files.pythonhosted.org/packages/e4/6d/1f5685e20f39604691bdc3c05aaa6b8bd2f954e9e996477adf2376768e33/rpds_py-2026.9.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:3edae8c5ddfdb6985d49ae9d150516e5076888879022f91a26c2de9276ce0bdb", size = 614842, upload-time = "2026-10-04T16:30:41.231Z" }, + { url = "https://files.pythonhosted.org/packages/c6/25/98652109fd9f7e10268dd4571aa52b81987001b806f37ef1a18260de714a/rpds_py-2026.9.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:0f045bb053c9057720d72c56dffe30dffdc05997b2897a827b9325f0ab6623fa", size = 582606, upload-time = "2026-10-04T16:30:43.345Z" }, + { url = "https://files.pythonhosted.org/packages/19/03/11ca09099bab5f53373a80a334c424ec917c13d050760a59499d2af5171e/rpds_py-2026.9.1-cp314-cp314t-win32.whl", hash = "sha256:bf35d0568abda97233239ce32896d3ad53fccc537832c104e30c94aa5fb93569", size = 203341, upload-time = "2026-10-04T16:30:44.954Z" }, + { url = "https://files.pythonhosted.org/packages/6f/8a/88909e3ffd9f47f5b58211473875d8c3c09079f0058c46fb72c55a702a26/rpds_py-2026.9.1-cp314-cp314t-win_amd64.whl", hash = "sha256:1e8d4d79d828299bf44a55db22a9388ab967b49d17132c88eab0f4360b48da8e", size = 222843, upload-time = "2026-10-04T16:30:46.486Z" }, + { url = "https://files.pythonhosted.org/packages/a3/90/a9ba81408369d34c1aca73319c15adc91ed45c1d21e9e8ee4832ffb7fe0b/rpds_py-2026.9.1-pp312-pypy312_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6cdc537c8633d7fd92a82e2e0d2ab74320a3f63d5e59fb9cf08711e08fe151c4", size = 381109, upload-time = "2026-10-04T16:32:08.994Z" }, + { url = "https://files.pythonhosted.org/packages/b3/44/5192a0bed94cec86bd2a5e1cc5a1bb8f7eec3aec907eacd2deb13e87e326/rpds_py-2026.9.1-pp312-pypy312_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:10e208f2425d973938afcd56e28a7c4be32e27b6a60b5d381f49fb9d8acf9759", size = 388222, upload-time = "2026-10-04T16:32:11.321Z" }, + { url = "https://files.pythonhosted.org/packages/92/cd/5356549711448f18b52f7a11ffbe90f1228774996fbf3c6cd1b18d22abaf/rpds_py-2026.9.1-pp312-pypy312_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6c0dbbcc19735fe5f8b0a54c07659d154a9e69f47e15d0a6ab7299215daf62cb", size = 500384, upload-time = "2026-10-04T16:32:13.478Z" }, + { url = "https://files.pythonhosted.org/packages/7a/88/ddda9d28adfe33119c75b2e733d4ba7e326f41d7e1b1093951771768ca48/rpds_py-2026.9.1-pp312-pypy312_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:684fd492fff4fead00587544e059be2bbcb6f93454f21fa2a91b66fc7508be82", size = 401661, upload-time = "2026-10-04T16:32:15.653Z" }, + { url = "https://files.pythonhosted.org/packages/32/c3/bb59ba16a6b4a57995d15b8c04c00f28df3b938c4dcdde48fbe0f73edb0c/rpds_py-2026.9.1-pp312-pypy312_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d1028417bb44037eb3069c1009bd7b7277212876cda22fbe565b0bca9fab6d2c", size = 380748, upload-time = "2026-10-04T16:32:17.727Z" }, + { url = "https://files.pythonhosted.org/packages/f5/b8/0580faa1c5a32ddc160130dd7ae54d2d007272a89a40a6b5db3e50f9f585/rpds_py-2026.9.1-pp312-pypy312_pp73-manylinux_2_31_riscv64.whl", hash = "sha256:492e5e428cbe126221611f47e068f01660352feec4ad18bc0f5ea9b2ae88fb14", size = 385731, upload-time = "2026-10-04T16:32:19.855Z" }, + { url = "https://files.pythonhosted.org/packages/bf/70/f73564642bbe3322c7eeef2c2f258cf040b71c41a430b532e59d04a1b838/rpds_py-2026.9.1-pp312-pypy312_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:88b5268892fde430d5531f95bc560b6efbbd67c929662c586afd729a96e7461c", size = 412131, upload-time = "2026-10-04T16:32:22.285Z" }, + { url = "https://files.pythonhosted.org/packages/8c/5d/17fff2e1f8f68721a2afb5cb48f7e442c751bb6b4883157c5abaf618cd08/rpds_py-2026.9.1-pp312-pypy312_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:01445c8d194aa032a08e944f16567672da1c62dbdbefd8b6d0693032e290cf68", size = 558193, upload-time = "2026-10-04T16:32:24.4Z" }, + { url = "https://files.pythonhosted.org/packages/2a/d0/869ab6fb08531f97aae4a780b4d61b4190d8aef48fe054ee28bcf7114466/rpds_py-2026.9.1-pp312-pypy312_pp73-musllinux_1_2_i686.whl", hash = "sha256:eef6a03b0b6d08d0835ccfa8ec8d1bc70525e3801387567137b50c557695e6da", size = 622448, upload-time = "2026-10-04T16:32:26.844Z" }, + { url = "https://files.pythonhosted.org/packages/ba/a5/9672e532fe02cd3b92c78cfd8743abca939d96853006c63835009a1ecb6f/rpds_py-2026.9.1-pp312-pypy312_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:6b9bf3135b4ad5981df9a73d71a35272d650a2985ae9c2746357b24d59de2448", size = 591126, upload-time = "2026-10-04T16:32:29.274Z" }, + { url = "https://files.pythonhosted.org/packages/80/ef/3a9f8e4279c7920af561deed4cb7ebebed2794a8f608cf404d5eef14a105/rpds_py-2026.9.1-pp312-pypy312_pp80-macosx_10_12_x86_64.whl", hash = "sha256:56c6952a9b15047466d0c2347c446a761d4527f89976156341e68f0ce5cc08b0", size = 357108, upload-time = "2026-10-04T16:32:31.641Z" }, + { url = "https://files.pythonhosted.org/packages/b7/55/4b2fa381a583760aea5c92841e4e928a358e1c6511b129219e9c2826a226/rpds_py-2026.9.1-pp312-pypy312_pp80-macosx_11_0_arm64.whl", hash = "sha256:b242c27c8f836305a4a72df9cdd564386ac57b807bd252a063223331c9316b37", size = 346877, upload-time = "2026-10-04T16:32:34.061Z" }, +] + +[[package]] +name = "ruff" +version = "0.17.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e9/a7/70debb024dfacda67b8e560cc7511f52b34b9348a8cc8c1ec23036dcd51d/ruff-0.17.0.tar.gz", hash = "sha256:5cd03240d8208a557c2a9655a5cb07ebe36aa6bb35065f97d48c1f6adef5a322", size = 5320554, upload-time = "2026-10-09T19:47:29.248Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bc/f8/ee5ab9da6089eae2a33e6008b01deb1eda19992c1c8e10661e98cee1640f/ruff-0.17.0-py3-none-linux_armv6l.whl", hash = "sha256:0e271826af9a20d18c6cfae8c51e82959167c24859686ddd3eb9a7f0842ce81e", size = 10148907, upload-time = "2026-10-09T19:46:38.695Z" }, + { url = "https://files.pythonhosted.org/packages/9f/d9/2f81fb5a9d580afbb11b1c8ff915233a11f2a1b27405d7991f183c5e1976/ruff-0.17.0-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:5f0ca4a40f81403689c04f12966e22f44e329ae362072d8f1587b7bda87f603b", size = 10224208, upload-time = "2026-10-09T19:46:41.711Z" }, + { url = "https://files.pythonhosted.org/packages/a7/20/643f3c8f75594f937b2bf74801241c56a2e2b8e139d24dff8b66b28cdd7f/ruff-0.17.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:cbf7149e0927dc3295d5d64679a4765576eef71b00782b2ae969ef82274d6bb9", size = 9273324, upload-time = "2026-10-09T19:46:44.323Z" }, + { url = "https://files.pythonhosted.org/packages/ec/91/627700b233d367736cb274f1bd0b47d1f2b12f68878192812bd875adadc3/ruff-0.17.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:13ee90156522998c3037059d8f66885c8adeeaf7643bdce2caceee196ecd23e0", size = 9953190, upload-time = "2026-10-09T19:46:47.155Z" }, + { url = "https://files.pythonhosted.org/packages/cd/92/91f7b5ed39490f89d6cbf56e1f543c383667a725efa8e2c2dee0f01f5591/ruff-0.17.0-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3d8e4a002a94cd9d0dc48b51dc69d807a172b5b9bf2b668e656424dc5b55ead1", size = 9961746, upload-time = "2026-10-09T19:46:50.098Z" }, + { url = "https://files.pythonhosted.org/packages/87/c5/7310f9fc63ce11ff6394edbd5e85433dfb0e14c9fbf6ccc97f1538491bc7/ruff-0.17.0-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c0b8a60c06a218c337e1161638d34757f83449243e2db161483ddf948e53ad14", size = 10625483, upload-time = "2026-10-09T19:46:53.379Z" }, + { url = "https://files.pythonhosted.org/packages/a5/8d/97443f0dca4a03a0bc7629fd396fd494a1cb6666121e38c5075acb217d8f/ruff-0.17.0-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a330178bdffc4205dbf3bda11d93e059e388fd6546f8cdd304501a9160363c0d", size = 11350129, upload-time = "2026-10-09T19:46:56.486Z" }, + { url = "https://files.pythonhosted.org/packages/9c/0a/c525efd9777be4b6b012e6969a3012648468e7e6c4b3e5b46af69f46e8eb/ruff-0.17.0-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7bb08489e234876fa2da67ae3ea938e9a2156da80293e0e4365abd6973d98329", size = 10906223, upload-time = "2026-10-09T19:47:00.263Z" }, + { url = "https://files.pythonhosted.org/packages/2c/3c/4a01195d93420cad1175bedad13a515dc8a56f95a6e39789b92e582682f5/ruff-0.17.0-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bc73e7c133e82d55b5f15897b2a442d72c0cb4a0c886c46801ce3c247150b60c", size = 10386314, upload-time = "2026-10-09T19:47:03.057Z" }, + { url = "https://files.pythonhosted.org/packages/c7/72/1a3951665485a921f6375f91e754a1854d5a645d41acc3642668064ff64d/ruff-0.17.0-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:db4f74c533403ab70fe4007873f6ae0c9f94a8b03158cf48d78788e47cdbe399", size = 10565464, upload-time = "2026-10-09T19:47:05.831Z" }, + { url = "https://files.pythonhosted.org/packages/90/8c/539b4d8c082f57e18db8ae2be85a460d77861c79dcd5798e32b536a6a06f/ruff-0.17.0-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:3d8cc360e666d1914e47b0777c6906d70cf18891a55532bd0a16844195d70859", size = 10079468, upload-time = "2026-10-09T19:47:08.617Z" }, + { url = "https://files.pythonhosted.org/packages/e0/b8/84286966db79434e8c26b585b0a0f6897cb3ab1c51a4aa4df10c28488b62/ruff-0.17.0-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:d66de796b726c4801e05fa99a2a8d7a780e107be222486c304ab61765561e866", size = 9958556, upload-time = "2026-10-09T19:47:11.324Z" }, + { url = "https://files.pythonhosted.org/packages/69/50/27b6eed27b83fcdd5bfa0d52b83231e29094754374698da404d094487ae3/ruff-0.17.0-py3-none-musllinux_1_2_i686.whl", hash = "sha256:c3f268baf004aea944f040623327119527ea231af15f7fb7890e82cea0679589", size = 10352173, upload-time = "2026-10-09T19:47:14.188Z" }, + { url = "https://files.pythonhosted.org/packages/2a/fa/955399fd13044cd827862044117d784a59e3196f6cce7424908ac9a7f914/ruff-0.17.0-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:864b6c1acb6b0bccf94b5a3938a1531fd09aaca5e5659a2e7bf0f3cf2a685540", size = 10791761, upload-time = "2026-10-09T19:47:16.931Z" }, + { url = "https://files.pythonhosted.org/packages/ae/bf/024e01e1f6aec87768696725e648ed5b438941341eea8f8100beb681961f/ruff-0.17.0-py3-none-win32.whl", hash = "sha256:5e50aa5b84decd9fe5b0bb0e6f71c3b592f1767ed09faa4b7207d933961e35cd", size = 9904112, upload-time = "2026-10-09T19:47:19.75Z" }, + { url = "https://files.pythonhosted.org/packages/cc/77/1ee73df41dcc8d1cdb686ee4bc46ea29704ea175feb6b95c78420f631ab8/ruff-0.17.0-py3-none-win_amd64.whl", hash = "sha256:8ab76bcda86dfd28e13776cb5de3c7bcdcf1ae3d37ed761113d1a5a415dc134c", size = 10115585, upload-time = "2026-10-09T19:47:22.698Z" }, + { url = "https://files.pythonhosted.org/packages/fd/71/eb4f0ccc844aece56963e8578df9c95d4c00f580547d52329d4035d3af18/ruff-0.17.0-py3-none-win_arm64.whl", hash = "sha256:c154c73ff43f9854395e24cac507af13078962e53d2b511605058d22af1fdb88", size = 9872035, upload-time = "2026-10-09T19:47:26.306Z" }, +] + +[[package]] +name = "safetensors" +version = "0.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/45/06/f955dbbb1859e3bd23c8ac6141af5106e7ad5fedec4a3a6e3d60f94b7001/safetensors-0.8.0.tar.gz", hash = "sha256:fabaf3e0f18a6618d9b36560682562157f77c2b71fcffc7b432be2baed9d753d", size = 325846, upload-time = "2026-06-09T07:52:25.563Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/39/a0/f718cda65b05407d228f97602cf60dca269c979867aa5beb25410de26cd3/safetensors-0.8.0-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:c554f85858e05226d3c2828e32395e677434685d6d94594a41643361c5e837f0", size = 473568, upload-time = "2026-06-09T07:52:18.829Z" }, + { url = "https://files.pythonhosted.org/packages/f5/b1/fa7c600e7dceae12e9606c7578cbc9ff1e1ed55844883ee5c92205e86226/safetensors-0.8.0-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:c80201d22cbf405b80647a60ada77bba06c8fba2da2743ba1e89cdcc39a81f25", size = 484562, upload-time = "2026-06-09T07:52:17.518Z" }, + { url = "https://files.pythonhosted.org/packages/09/7d/65a7de0af421317bb36a067241e4235fff194eed60b961ed6d3f59a3fc60/safetensors-0.8.0-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7a46e5ff292c356d6991e60942ba7f79817682d3a2cef0702136448cb9c4d235", size = 502844, upload-time = "2026-06-09T07:52:07.624Z" }, + { url = "https://files.pythonhosted.org/packages/91/4f/3175c9d75634e0e0dda0082794193521035edd7c70a6f212bf33ca06ddf4/safetensors-0.8.0-cp310-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:4124502b78f03534117c848f87a39b8f31e577b15eff423bf8bfb95f2a8c30d0", size = 511823, upload-time = "2026-06-09T07:52:09.565Z" }, + { url = "https://files.pythonhosted.org/packages/20/87/846c289e7aa2299eff406335717cf43ce8777194ece8aad75772e0411615/safetensors-0.8.0-cp310-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7bc0a787ba8a35be368ee3574edfa2b1ad389eebd0a72e482ae275490e3f6c98", size = 633461, upload-time = "2026-06-09T07:52:11.128Z" }, + { url = "https://files.pythonhosted.org/packages/76/22/8d64d9df2c45d5ded401df889d0ad90882804ca172d79ec4f0df8f727fe0/safetensors-0.8.0-cp310-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:040070828e36dc8e122178bbbd5830ff9e97920affb84cbe0f46442497bed358", size = 545148, upload-time = "2026-06-09T07:52:13.603Z" }, + { url = "https://files.pythonhosted.org/packages/28/50/f203ff3a3ddfe19308efc83c5a3a29ed02bf786732ec35e68bf9162f3365/safetensors-0.8.0-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fd6f3f93c9a0a7cc2788ee63fb763353d4bd2e89b0751bc78fcf7dda00bea774", size = 516040, upload-time = "2026-06-09T07:52:16.29Z" }, + { url = "https://files.pythonhosted.org/packages/46/fb/cdaed17ceb2948784fd9c36b6fd3e951b608547cea81a48e8ee6f8cfdfcb/safetensors-0.8.0-cp310-abi3-manylinux_2_31_riscv64.whl", hash = "sha256:fcdd41ec4628fee5799f807c73c353629130fbd942aa23d83c623dd6c9d52d78", size = 513832, upload-time = "2026-06-09T07:52:12.37Z" }, + { url = "https://files.pythonhosted.org/packages/0d/49/1e15de264dcc3b77943d2d0c56a95809956883b1c2d6d585c792523f180b/safetensors-0.8.0-cp310-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:8e9f537aa183a38ace122d27303dcd986b26bd2a7591f9181d7f0c396f4677ca", size = 559930, upload-time = "2026-06-09T07:52:14.743Z" }, + { url = "https://files.pythonhosted.org/packages/2a/43/bf38443278eab4b1be1fce2931e2b012ad9cb7df52ada751d0aab8f7659a/safetensors-0.8.0-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:87eec7ffed2b809f05a398a8becb7d013f19f7837cd15d9748580d6cf30dbaf4", size = 678670, upload-time = "2026-06-09T07:52:20.032Z" }, + { url = "https://files.pythonhosted.org/packages/72/e3/68cd3fa5b48488e84add63e04cb12f3bc28ae4638c06d4508c6e88823d0e/safetensors-0.8.0-cp310-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:4a95ae2b05d7726d751da4ebf626a2ca782b706e101bd894c95bc2450b1cffcc", size = 786679, upload-time = "2026-06-09T07:52:21.322Z" }, + { url = "https://files.pythonhosted.org/packages/29/4b/1c19c509d56e01f4fbb3d0a2e597450f6cc04d1d56cf52defb0a62dfd715/safetensors-0.8.0-cp310-abi3-musllinux_1_2_i686.whl", hash = "sha256:3ae091f16662658bdc019a4ff6cb4c085bb7d725eb5978b183ffd265863b6d2d", size = 765683, upload-time = "2026-06-09T07:52:22.594Z" }, + { url = "https://files.pythonhosted.org/packages/27/43/41c1621732edd934d868a00d1b891584c892a7b62a9aab82ea5a0a5623ee/safetensors-0.8.0-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:8e080062fcde23be189565e1c3305d16751a218ecf9412c8601e64204eb6f846", size = 722361, upload-time = "2026-06-09T07:52:23.924Z" }, + { url = "https://files.pythonhosted.org/packages/8e/3f/73ccf82579412b4a71c4ca673f10b5f1f888d7cf5af7fe24f27d30307be4/safetensors-0.8.0-cp310-abi3-win32.whl", hash = "sha256:2ddf52eac562eda224f99acfa7889d02968c1fd59a5b011ae7d8137c37e9c02d", size = 342401, upload-time = "2026-06-09T07:52:28.895Z" }, + { url = "https://files.pythonhosted.org/packages/1b/6d/3fba214c1e5e0f69991677ec3bc17023f0421776975e1de0c682dca475e2/safetensors-0.8.0-cp310-abi3-win_amd64.whl", hash = "sha256:096ec1a98435df7beb08853bb5aa9081a84f23d0adc67ed1a0a10550f608373f", size = 355540, upload-time = "2026-06-09T07:52:27.832Z" }, + { url = "https://files.pythonhosted.org/packages/8d/fc/7eedc3510d97878876e32774eebbeb61c43f148a96e915c84229a3e967aa/safetensors-0.8.0-cp310-abi3-win_arm64.whl", hash = "sha256:f7838e5135a406ad3e02efdcb8cf2e5397d368b0154537c4fec682dbc544d452", size = 340500, upload-time = "2026-06-09T07:52:26.745Z" }, +] + +[[package]] +name = "scikit-learn" +version = "1.9.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "joblib" }, + { name = "narwhals" }, + { name = "numpy" }, + { name = "scipy" }, + { name = "threadpoolctl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d2/eb/eaf5e07fcc0da7149b0e084f24e54edd7441b9a89ce7e034032ae97fe3a0/scikit_learn-1.9.1.tar.gz", hash = "sha256:629cada3e33e2b9bf376cdc7614a47a4140b8aedc1d836579e359736fbd82977", size = 7786908, upload-time = "2026-09-10T18:34:04.679Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/df/a7/25f0a43d2fde306e8ef45f45121192f687b79beaf4bae8c21607c46c5e63/scikit_learn-1.9.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:0c0f8b5d09b44101cea2767f300680bada1ea27f976fe4b48b83950a4f55a49a", size = 8775209, upload-time = "2026-09-10T18:32:42.804Z" }, + { url = "https://files.pythonhosted.org/packages/60/ea/57e57539ce175d774fc291ed091b0a6d756854b92cd92554c6bb4d0ae498/scikit_learn-1.9.1-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:8c14ce41d561f7749f990b41d6703fe02c4669fbc485e598e069e0a1967b488e", size = 8295541, upload-time = "2026-09-10T18:32:45.069Z" }, + { url = "https://files.pythonhosted.org/packages/78/2b/5721a174406bfba49bce20ae997b3b64cf355c3f623a2638284ab6a82156/scikit_learn-1.9.1-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e4c20a6c017d820faa7ac8c783e3d0c6a9a2e297bf9f55332ca17cdf7fd4d04d", size = 8871592, upload-time = "2026-09-10T18:32:46.999Z" }, + { url = "https://files.pythonhosted.org/packages/8e/57/a50162f3d29feb979ab6347c6debda506dfb525bcff3c50dd17606651c7e/scikit_learn-1.9.1-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e5d7b18a5b9dca241a74695f3275fa4c895a9dadc72b3d8df5fa9d1083c9b83e", size = 9166043, upload-time = "2026-09-10T18:32:49.343Z" }, + { url = "https://files.pythonhosted.org/packages/72/8d/27c054166bac671770d1ea0ef7716134fe8119c0df224a58c89fd735a61c/scikit_learn-1.9.1-cp312-cp312-win_amd64.whl", hash = "sha256:4b59abb30618121cc46b45972d6bf53a7128b4df4cd346c6ca6f4d5f9031e49c", size = 8262238, upload-time = "2026-09-10T18:32:51.679Z" }, + { url = "https://files.pythonhosted.org/packages/9d/d6/493086006ea0c68ad62c40a8dece1961b61bf503f45400f133d47f56e5be/scikit_learn-1.9.1-cp312-cp312-win_arm64.whl", hash = "sha256:d5945a2908be62350e2978344e62b56c1552c2ca4f844ebf6277c94944d647dd", size = 7902102, upload-time = "2026-09-10T18:32:53.67Z" }, + { url = "https://files.pythonhosted.org/packages/bb/8d/b60d5e7354ff0ff5cc9400e60273696589d87a30b8b2235886a76d80d062/scikit_learn-1.9.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:2c2b312fd8c02951a364fa120ea08c1cec10d863466bf1701b013152d7537835", size = 8739300, upload-time = "2026-09-10T18:32:55.483Z" }, + { url = "https://files.pythonhosted.org/packages/2f/81/3c6392c03665d2899457a76e535a9a6f597dddddf3220fd2e1d790da88c5/scikit_learn-1.9.1-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:61cd968ab831a76d0ecbaf0347ab2270268716da28f94fd022497e3d6f205f13", size = 8262364, upload-time = "2026-09-10T18:32:57.966Z" }, + { url = "https://files.pythonhosted.org/packages/0f/35/a15b8653499692879821301d48059376d6e68e8b65cd0f22d19b6ee83cd9/scikit_learn-1.9.1-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5990f9c69e431bfaddcde1a6d7c5355243e026bc9b9e560c13893b90dab53fb4", size = 8823698, upload-time = "2026-09-10T18:33:00.632Z" }, + { url = "https://files.pythonhosted.org/packages/23/e5/688703d357e5393f708d98eb189fd415ae69e39f6de03c6bd4005aef6118/scikit_learn-1.9.1-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:55e79d6e9b0923f1a978179822bd43d7f5543f45e970a00fe861f43486380aba", size = 9121732, upload-time = "2026-09-10T18:33:02.825Z" }, + { url = "https://files.pythonhosted.org/packages/96/45/a10add34c08184d373be9384660c75758128ca881ed27b503b6f6a742478/scikit_learn-1.9.1-cp313-cp313-win_amd64.whl", hash = "sha256:2070f271e5375dc42c6bb93b461ab1c0aa5841d4009267e0cfd95a39dca94a43", size = 8237244, upload-time = "2026-09-10T18:33:05.26Z" }, + { url = "https://files.pythonhosted.org/packages/9e/08/7a89bcdadd1fff0d464d01056417b646c9abcbc54f7297a0a1203bba5ebb/scikit_learn-1.9.1-cp313-cp313-win_arm64.whl", hash = "sha256:613f0a783ca05aa844a4e1ac42d48425058f2c52be73f40f8cd98b7cd111acd6", size = 7875398, upload-time = "2026-09-10T18:33:07.506Z" }, + { url = "https://files.pythonhosted.org/packages/64/e3/b58e45082dcf3dcf0eb1192ee03545ec43d8c98441dfe20e88afca8442ce/scikit_learn-1.9.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:d5d117952769b563067656784e03c75a2d8235a7a05cf7fffa78a311e75aac08", size = 8747414, upload-time = "2026-09-10T18:33:10.698Z" }, + { url = "https://files.pythonhosted.org/packages/9b/ed/d68115577c8b42b0442ebd8180945d4008880a33176094640e00b585128e/scikit_learn-1.9.1-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:8893bc6331f60f18d4ac75e12ed356e2dcf6a564bf767918b5b7ca54c8c8be49", size = 8279175, upload-time = "2026-09-10T18:33:12.846Z" }, + { url = "https://files.pythonhosted.org/packages/c4/6c/06c7eb61a438e389cbf3f7210897069bec5a883dbe03c189ae792781e11a/scikit_learn-1.9.1-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b5492cf2df5226691c32611de8734bcf42148c6547ae53c7f4e6b847793addc0", size = 8881706, upload-time = "2026-09-10T18:33:14.741Z" }, + { url = "https://files.pythonhosted.org/packages/86/4e/0bab75490ca4b85fad8388739c7ebc71d9db553f8c69e39943ee8db0aaae/scikit_learn-1.9.1-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:993d332ff80e62efae9e39603b7e872297c418d780f01a01855269a3489c950f", size = 9152030, upload-time = "2026-09-10T18:33:17.436Z" }, + { url = "https://files.pythonhosted.org/packages/9a/13/31c6f8ba1b7eecef9dd9558576c752d2ec5785fd0e456bb9fc59305be23f/scikit_learn-1.9.1-cp314-cp314-win_amd64.whl", hash = "sha256:ca9051447455dae341d4d591eece7deb2d8e3d1020298fc87a81fc51e4da8f53", size = 8377024, upload-time = "2026-09-10T18:33:19.941Z" }, + { url = "https://files.pythonhosted.org/packages/62/e6/6d3cb8a45f5228f915acd66b819dd6b8232ccbe24532f51d278e3991df31/scikit_learn-1.9.1-cp314-cp314-win_arm64.whl", hash = "sha256:90de6573f733a9fb79476ff1371af52a397d41c8b35f9146e20923db010d67b6", size = 8014196, upload-time = "2026-09-10T18:33:22.126Z" }, + { url = "https://files.pythonhosted.org/packages/66/6e/6befb2d5961490d18d9dbc16a5df37aa121d08bc9893316a6a363977a903/scikit_learn-1.9.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:7b5cad1624de8b75e5b9ccb7b0ce1ff1d01306340a3efc56d5529c5ba92392eb", size = 9066066, upload-time = "2026-09-10T18:33:24.396Z" }, + { url = "https://files.pythonhosted.org/packages/cb/18/11271f2f7db337db01f598e358721b1e83989407131272e5dd64214c28a8/scikit_learn-1.9.1-cp314-cp314t-macosx_12_0_arm64.whl", hash = "sha256:d137ce8a6142029fb5c35bd82f470c40cd9e760e5e2f7694b362c497c4ab3fa2", size = 8647654, upload-time = "2026-09-10T18:33:26.649Z" }, + { url = "https://files.pythonhosted.org/packages/e7/04/9c15d201e1b6a2e81b8215865df7646c5a360f560831769c8dc92ac1ab9a/scikit_learn-1.9.1-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:66f852f7325b5070bc28329005aca76055a2def78faac039548ae889aeaa45a6", size = 8889070, upload-time = "2026-09-10T18:33:28.95Z" }, + { url = "https://files.pythonhosted.org/packages/1a/5a/4cb6c85160af4a639e87a3b7bf8b1c25cfc3b504c5af710ca416a6dcfc5f/scikit_learn-1.9.1-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:748bcb0a4cc04aec470652c9e5ec68450948e867387e7dfade647107ade68d25", size = 9131227, upload-time = "2026-09-10T18:33:31.008Z" }, + { url = "https://files.pythonhosted.org/packages/00/0e/361440972ae3d19b90ea88a84791138a51de0e8432a770ba741b2c8d9ced/scikit_learn-1.9.1-cp314-cp314t-win_amd64.whl", hash = "sha256:38cd925e893e5539be704d5edc64dbe081aacdab6b89d8c2977c1f6a7a453ce5", size = 8683001, upload-time = "2026-09-10T18:33:33.188Z" }, + { url = "https://files.pythonhosted.org/packages/e2/8f/a9f405c5c0e2df6f343a871b40c97fb32969e3ccc38e3033dd118f3c261e/scikit_learn-1.9.1-cp314-cp314t-win_arm64.whl", hash = "sha256:b01e5b01735d38474127ca3f49319b592506225a87793b27559816b5c75cea39", size = 8257996, upload-time = "2026-09-10T18:33:35.343Z" }, +] + +[[package]] +name = "scipy" +version = "1.18.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7e/74/66de6258867beb2ef08f35f9f2ac017a52cacd5081714d239ff1a442d458/scipy-1.18.1.tar.gz", hash = "sha256:52c4b7422442aba924d03ad4019852b08a92e64ea187b933135687bfe2747307", size = 30781235, upload-time = "2026-08-21T23:28:50.599Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/f7/240c110c08693826b4513a52f5717d62ec7c7af72f2920821247c03b17b3/scipy-1.18.1-cp312-cp312-macosx_10_15_x86_64.whl", hash = "sha256:457fd7a2a8edeb044ab6ffbc0aa03ff6cd18491356e5e0c834d76ce621b916d1", size = 31111061, upload-time = "2026-08-21T23:23:44.522Z" }, + { url = "https://files.pythonhosted.org/packages/05/4a/78c6285577c375e7cf27277ea8ee6961224327f1e1a0c44af5f17f23635c/scipy-1.18.1-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:e708533e8b2ae2497d65346538a7dcc92814410b25b81432eac66de0f2af8265", size = 28733332, upload-time = "2026-08-21T23:23:50.015Z" }, + { url = "https://files.pythonhosted.org/packages/a5/f6/a5b82f8abbe14d134691b8b903696f701d25a081353a29dc655c364d9e62/scipy-1.18.1-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:7bbf207c4453ce1ad2e00b17313852b33310b83090c2311bdaf97f93c0380d12", size = 20475078, upload-time = "2026-08-21T23:23:54.138Z" }, + { url = "https://files.pythonhosted.org/packages/23/22/0858a0bbd6b3e825ceb8cd9baf9eaf3b2f2b1d77727eb6be40500bcdc92f/scipy-1.18.1-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:78c0665edead396b1abb4897c41a5c1d9bf090c8a637a4c20a61678e0a264e66", size = 23108904, upload-time = "2026-08-21T23:23:57.824Z" }, + { url = "https://files.pythonhosted.org/packages/75/9a/2e71719f31eaefe0e3a1706c4a1ded94e664bfd95ffca2b219a671faee01/scipy-1.18.1-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3c085faa2cfa879c5141df483f836f4d691045a078224a670fa570fa01612d89", size = 34025113, upload-time = "2026-08-21T23:24:02.209Z" }, + { url = "https://files.pythonhosted.org/packages/df/64/ff35eb9e54894cf471ff4716abd3c81eb0a0626869217ce3e6ba4ccf17d7/scipy-1.18.1-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f55fa87b6c612ecd6b058f167c53231b1d14e412efe361d3d6e38b3631c73218", size = 35344199, upload-time = "2026-08-21T23:24:07.844Z" }, + { url = "https://files.pythonhosted.org/packages/d3/af/c5538be1792f7034c12c7db6ee67cace58253c7b87b122d68253eaf5de89/scipy-1.18.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:c35d74ce0e193ff740c2f2be2ac913ddc232fe6c1ff40b26cfecb9c670c63314", size = 35639587, upload-time = "2026-08-21T23:24:13.05Z" }, + { url = "https://files.pythonhosted.org/packages/91/4c/075e4f66471bac101141ac739e9e135549be1bae584571bd03a530c056e1/scipy-1.18.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:d2924a03db38dc2e848bca2fe9f077dafb891480b91a00a0963a8cf86dfc31c1", size = 37480330, upload-time = "2026-08-21T23:24:19.608Z" }, + { url = "https://files.pythonhosted.org/packages/39/e7/979fd14e75008623df31ba70d6bb144700f68feadcea042021c06a05bf82/scipy-1.18.1-cp312-cp312-win_amd64.whl", hash = "sha256:5e4d44984abc0020154ea81b247adeddcc3ac5527b975ff798bd1ba0adc513c2", size = 36658278, upload-time = "2026-08-21T23:24:25.463Z" }, + { url = "https://files.pythonhosted.org/packages/c7/0b/e1525354ff9d7d5feb6d1b31af6d14072e5c91e9607b421fa1ec889660b3/scipy-1.18.1-cp312-cp312-win_arm64.whl", hash = "sha256:d65d448389b8436493abcf629cc94ad0cf32aecaf06e1acca1de53cc795f2f12", size = 24400588, upload-time = "2026-08-21T23:24:30.579Z" }, + { url = "https://files.pythonhosted.org/packages/b6/55/4540ee0f9c42a9ad7109d0d1a8cc70de54c3572b01c6693a2b1c70e90ceb/scipy-1.18.1-cp313-cp313-macosx_10_15_x86_64.whl", hash = "sha256:3ab3523da44749156e1f68b464dc56af11ae4cbc5c739a49d05f32b982eca9f3", size = 31089958, upload-time = "2026-08-21T23:24:35.8Z" }, + { url = "https://files.pythonhosted.org/packages/2a/f5/769f36d14922b8071a43e95d24d18b6bdafad10d7f5cf647867e1ac052bc/scipy-1.18.1-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:e6fb6a55cc0ba97b59a1f288fb86dc6fce8bdfc0fffcbfd015e3a954bf2a2d93", size = 28715106, upload-time = "2026-08-21T23:24:40.775Z" }, + { url = "https://files.pythonhosted.org/packages/9a/d7/21d890274f75ea37a8209d5519e72da3da90302e3b9fb8397a0918386a62/scipy-1.18.1-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:ea324d9dd34c38bfb9bec8ca4d1b407db97dbb74029f566b8e322b1b6fe56fe6", size = 20456846, upload-time = "2026-08-21T23:24:45.066Z" }, + { url = "https://files.pythonhosted.org/packages/ec/01/798430ecea2e78ec7c02663d5f71c007bb6abeca931080debd40d7fa55ea/scipy-1.18.1-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:75b00eb8fb802090aa903f4ea1c7f5a584779f967361e68b7e98e531cc2d7174", size = 23087986, upload-time = "2026-08-21T23:24:49.539Z" }, + { url = "https://files.pythonhosted.org/packages/e6/5f/4634e9d35c68496e4e34cb6946eafab044458e6cedab42b40b6588e475b6/scipy-1.18.1-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d416b16cccfd70fbf62400e84d0bb2f4e6af519a45557f1692c749b37f14b315", size = 33998146, upload-time = "2026-08-21T23:24:54.714Z" }, + { url = "https://files.pythonhosted.org/packages/41/48/6450ed9243315322bbc19ac57b9b70d66a20bf1d38d124c96bc4bf6af9ea/scipy-1.18.1-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fdaf5ea890a6183d0565f51a61799d67081bd5b1cf03c5f4b3fd3732108625c9", size = 35312578, upload-time = "2026-08-21T23:25:00.44Z" }, + { url = "https://files.pythonhosted.org/packages/00/bd/bf5a4be6a3525676499f6dff307991739ff6fdcad1481b1aeb6745339f58/scipy-1.18.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:c825cef2f49e46753726a7181a8e199804a912b29519ada542c6ebc654951899", size = 35612621, upload-time = "2026-08-21T23:25:06.144Z" }, + { url = "https://files.pythonhosted.org/packages/bd/4e/3c45c33e00a77996c4b1cb707929f833ba7b1d522ee29f882512c330676d/scipy-1.18.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e3b417bf8c2c7c16e8f58ad91db17783ec911ac16e7b50eb6eab6e809b4f5b07", size = 37457323, upload-time = "2026-08-21T23:25:12.483Z" }, + { url = "https://files.pythonhosted.org/packages/93/0e/e0348fbc0dbab65c114cf78957e7dfeb49f8e8b556b4d930cc12ff195e18/scipy-1.18.1-cp313-cp313-win_amd64.whl", hash = "sha256:559ed65f60c1af5a03f3912605a1b5114f522c7c32fb23c3376ae8f03219fe28", size = 36622841, upload-time = "2026-08-21T23:25:18.722Z" }, + { url = "https://files.pythonhosted.org/packages/50/a8/6a77f5f267c555108f0a864b6db714363dab567a8266422a79a385f9232b/scipy-1.18.1-cp313-cp313-win_arm64.whl", hash = "sha256:cd479fc04dd9401e3b4f49e76518768ef99c4f517a98c284eb091fd725719adf", size = 24399315, upload-time = "2026-08-21T23:25:23.458Z" }, + { url = "https://files.pythonhosted.org/packages/06/d5/d8eb4e280ddb56a4ab2c6f02ee49b56b23f6e977cf0802fd6d68dbef14f5/scipy-1.18.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:83de5453a7799afc9048b4616bd085cef126e36412f0ea2f6370c36a2a3a51e7", size = 31090936, upload-time = "2026-08-21T23:25:28.686Z" }, + { url = "https://files.pythonhosted.org/packages/2a/49/59ea385dc3a62ff498ddf3cfff7c2b41b0f9f9d3c4122b3f1dcb6d6327fe/scipy-1.18.1-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:9554bcc6d715ee87a633a3cc8e7703c6628b100dd29cb8a2efc4c0533c7ff729", size = 28725221, upload-time = "2026-08-21T23:25:33.244Z" }, + { url = "https://files.pythonhosted.org/packages/70/e8/6b0c288c50942d78193696c9f15f9a0874f5178aa0ddf40f83d9924b3e8d/scipy-1.18.1-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:011413b7426b75012840e35649e00fe0a2c3bae89fed433876e3a99251572efc", size = 20466839, upload-time = "2026-08-21T23:25:37.516Z" }, + { url = "https://files.pythonhosted.org/packages/4b/e0/54fd3793c729e3b936782f181b59cbb1205bf250ab605a16cb1ba61cdd5e/scipy-1.18.1-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:88f0e784020649f88ea48c9f5ddfa403bf9205820667c0914740b392035afb82", size = 23089121, upload-time = "2026-08-21T23:25:42.019Z" }, + { url = "https://files.pythonhosted.org/packages/0b/56/030af62bea3cf878e0028515dff78c123b01633606a879b63f42d2db99cc/scipy-1.18.1-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2d3ab0e8c69a17dd3559eab8cbb88f258e285c94d572c2719033f90f83290c89", size = 34053851, upload-time = "2026-08-21T23:25:47.998Z" }, + { url = "https://files.pythonhosted.org/packages/6b/89/2a844506d49651e9aa1af6ef95b6bd8031cb1d5a4375edec6155037e04cf/scipy-1.18.1-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ac0333bdf38309aa3dcbe7e3fa7ea29e7a2c37c6ea306a757b700ded8e4596ad", size = 35329183, upload-time = "2026-08-21T23:25:53.522Z" }, + { url = "https://files.pythonhosted.org/packages/eb/56/c7370c3640e92ac9613cbf26cb3f729f9b12ddf1727b55b94b53b24d6f48/scipy-1.18.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:911de823097db8b63f034299d12662db93344e6ffa0b881cbb57748974b70168", size = 35672551, upload-time = "2026-08-21T23:25:59.387Z" }, + { url = "https://files.pythonhosted.org/packages/24/16/ec8536f351421f8bf60a1120930638f83790f4710b8230446aca3d6159d4/scipy-1.18.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:95298364e251be3e60249facbeeca03631d3bb7584f85879516ec55ac717b81f", size = 37469416, upload-time = "2026-08-21T23:26:05.432Z" }, + { url = "https://files.pythonhosted.org/packages/52/94/d73da0d28f16c45bb9b0a5691b91610b0275c5ef0eb5e43c87cf2dc1bf31/scipy-1.18.1-cp314-cp314-win_amd64.whl", hash = "sha256:78a0d7c918e74a232394117160e7e3db503377572a45bcef8826e4ab8a35feba", size = 37362755, upload-time = "2026-08-21T23:26:11.366Z" }, + { url = "https://files.pythonhosted.org/packages/89/25/e996e4dc74e10e227b1e14db5eaf6608bb6dd33884a64851c38f18dd4249/scipy-1.18.1-cp314-cp314-win_arm64.whl", hash = "sha256:cbf38d043c1aa4ab306e1ada6ab6eddacc3322a20b7af1b30bc93254b366fe09", size = 25036090, upload-time = "2026-08-21T23:26:15.887Z" }, + { url = "https://files.pythonhosted.org/packages/fa/c9/c00213f92309d753b48903e6a451b87eb52ff5b7a16e789d1568bbf221c4/scipy-1.18.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:0fcb3c93519f27bb4f0c4b0f7802cdcaca7fcf93267b75edda2e9f4e8a55cbd7", size = 31485550, upload-time = "2026-08-21T23:26:20.776Z" }, + { url = "https://files.pythonhosted.org/packages/74/b2/e3067c487982d4eeab2938928529410370c06fea84a4d3f4925e7d96647d/scipy-1.18.1-cp314-cp314t-macosx_12_0_arm64.whl", hash = "sha256:ddef79fb382df40104a19bb7151b3b23e57c1778fcf857c71ceecd9bd264513f", size = 29174642, upload-time = "2026-08-21T23:26:25.395Z" }, + { url = "https://files.pythonhosted.org/packages/d5/ab/374c9fe2d1ec014e576c781a4b5d8e1ba340e8f6b4638c16f711d2b194f0/scipy-1.18.1-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:0e82073ecc7acc6436fac4b31674109c7e1d3e596789767eda01258a8c9e8123", size = 20916357, upload-time = "2026-08-21T23:26:30.112Z" }, + { url = "https://files.pythonhosted.org/packages/90/38/223915c88a17317cafbf8ca2a42b11c265a9fb1e804aa665544132b5fe8a/scipy-1.18.1-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:8bcf3c1ba5d6456e2effd30fcbd3459b044d683fcdac79a2e6830f0bdf7de487", size = 23482611, upload-time = "2026-08-21T23:26:34.846Z" }, + { url = "https://files.pythonhosted.org/packages/c4/d1/db0948da8ca57a80b36520ef0a768b967d99f3af65f4b6f1bf6362ad4dd4/scipy-1.18.1-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cfbf154f2ba187f2ed6cce2639efff7d105f1140573642c0161615b6d91d6a87", size = 34143202, upload-time = "2026-08-21T23:26:40.4Z" }, + { url = "https://files.pythonhosted.org/packages/87/53/39d046cc7574ed6acacb6bd5723e220107ece80bff12faaf3efc4ddeede4/scipy-1.18.1-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a1d33a7836f7ddc1993427966a0823468ec41bcbdb1a9f9942d1d7e57f803ba3", size = 35380876, upload-time = "2026-08-21T23:26:46.1Z" }, + { url = "https://files.pythonhosted.org/packages/f9/da/32e0e799d875a85ca57d9bde6c78148afcc0e38276df683d95854eadc8c3/scipy-1.18.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7f4b8bc363b6d65ee2152bec57568e3c52639bb34c46057b09857a307ed5e21d", size = 35770885, upload-time = "2026-08-21T23:26:51.533Z" }, + { url = "https://files.pythonhosted.org/packages/88/2e/f97a666d362fee68b18f41c9c30ed502ca5c98b549749bfcb52a8b74d1eb/scipy-1.18.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:11c423f1049c5755ad4409af52a9ada1cff96fe9b50795d4af3619f292901239", size = 37525424, upload-time = "2026-08-21T23:26:56.751Z" }, + { url = "https://files.pythonhosted.org/packages/ca/d5/a9e765a84654ebba8479a1fd1b059ced1af72b168a3b2a3a46540ea38d20/scipy-1.18.1-cp314-cp314t-win_amd64.whl", hash = "sha256:c24acac1e18912761c4700239bbc1fd32f615af690f1584d49b35859be51324d", size = 37416961, upload-time = "2026-08-21T23:27:01.546Z" }, + { url = "https://files.pythonhosted.org/packages/ee/16/e79e0d1c63ef698879d85439d37e9fb434e3b804e506a6991038d086ebd9/scipy-1.18.1-cp314-cp314t-win_arm64.whl", hash = "sha256:9f2897bf7737392ad0d5213ea7b6add72a4edf5679b3153106aeb88b6507b3b9", size = 25331848, upload-time = "2026-08-21T23:27:05.884Z" }, +] + +[[package]] +name = "sentence-transformers" +version = "6.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "huggingface-hub" }, + { name = "numpy" }, + { name = "scikit-learn" }, + { name = "scipy" }, + { name = "tokenizers" }, + { name = "torch" }, + { name = "tqdm" }, + { name = "transformers" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c4/a1/53ae87971817e2d8370f8e79b843a881be33ab339502d71c5f82ac31f7af/sentence_transformers-6.1.0.tar.gz", hash = "sha256:299025df51550dc1a38f05be27a9b0bf881c4e5e70542b3b7757d05e00aa3868", size = 576566, upload-time = "2026-09-18T10:44:24.279Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b5/6e/9115e19589c83172bd37ec793cfef7f7c719464053fa6a8882700c1f4488/sentence_transformers-6.1.0-py3-none-any.whl", hash = "sha256:eb8122f4d180f552eda26dc3d77e84e8c11dc2b1d456a406b9f24abb70ceeadd", size = 740560, upload-time = "2026-09-18T10:44:22.841Z" }, +] + +[[package]] +name = "setuptools" +version = "84.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6d/44/f5da03a8ef95d369145c5bb53050e7877c9f3d312e128605fd9504829143/setuptools-84.0.0.tar.gz", hash = "sha256:f4695c21257f0d9b537ec2692c941d02ee143b7cc1276941349a546573b2ef73", size = 1168449, upload-time = "2026-08-08T18:27:58.365Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/95/9c/c510029fc6ef33a6275cd2c5d3cecd6613dfd6aa401d57c54f1c18852ccf/setuptools-84.0.0-py3-none-any.whl", hash = "sha256:51a52592b3b99e102b609654876bd65f19f999935166d1352678931132b0c670", size = 818216, upload-time = "2026-08-08T18:27:56.719Z" }, +] + +[[package]] +name = "shellingham" +version = "1.5.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/58/15/8b3609fd3830ef7b27b655beb4b4e9c62313a4e8da8c676e142cc210d58e/shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de", size = 10310, upload-time = "2023-10-24T04:13:40.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755, upload-time = "2023-10-24T04:13:38.866Z" }, +] + +[[package]] +name = "sqlite-vec" +version = "0.1.9" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/68/85/9fad0045d8e7c8df3e0fa5a56c630e8e15ad6e5ca2e6106fceb666aa6638/sqlite_vec-0.1.9-py3-none-macosx_10_6_x86_64.whl", hash = "sha256:1b62a7f0a060d9475575d4e599bbf94a13d85af896bc1ce86ee80d1b5b48e5fb", size = 131171, upload-time = "2026-03-31T08:02:31.717Z" }, + { url = "https://files.pythonhosted.org/packages/a4/3d/3677e0cd2f92e5ebc43cd29fbf565b75582bff1ccfa0b8327c7508e1084f/sqlite_vec-0.1.9-py3-none-macosx_11_0_arm64.whl", hash = "sha256:1d52e30513bae4cc9778ddbf6145610434081be4c3afe57cd877893bad9f6b6c", size = 165434, upload-time = "2026-03-31T08:02:32.712Z" }, + { url = "https://files.pythonhosted.org/packages/00/d4/f2b936d3bdc38eadcbd2a87875815db36430fab0363182ba5d12cd8e0b51/sqlite_vec-0.1.9-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4e921e592f24a5f9a18f590b6ddd530eb637e2d474e3b1972f9bbeb773aa3cb9", size = 160076, upload-time = "2026-03-31T08:02:33.796Z" }, + { url = "https://files.pythonhosted.org/packages/6f/ad/6afd073b0f817b3e03f9e37ad626ae341805891f23c74b5292818f49ac63/sqlite_vec-0.1.9-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux1_x86_64.whl", hash = "sha256:1515727990b49e79bcaf75fdee2ffc7d461f8b66905013231251f1c8938e7786", size = 163388, upload-time = "2026-03-31T08:02:34.888Z" }, + { url = "https://files.pythonhosted.org/packages/42/89/81b2907cda14e566b9bf215e2ad82fc9b349edf07d2010756ffdb902f328/sqlite_vec-0.1.9-py3-none-win_amd64.whl", hash = "sha256:4a28dc12fa4b53d7b1dced22da2488fade444e96b5d16fd2d698cd670675cf32", size = 292804, upload-time = "2026-03-31T08:02:36.035Z" }, +] + +[[package]] +name = "sse-starlette" +version = "3.5.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "starlette" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e4/be/0123026f719d1a7936f214a88b553bb5701e04ff2511147c1dab0c5035eb/sse_starlette-3.5.0.tar.gz", hash = "sha256:75de713aa8a9441513cc283220826da079d982770965b951e9437720e8bafdb2", size = 36794, upload-time = "2026-09-28T17:48:14.7Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/be/e4/cdda14023c316d71493bc54fdffc3dd006631b88866145c9d3cc33e0f1df/sse_starlette-3.5.0-py3-none-any.whl", hash = "sha256:3e6e1070df3f0f5d9cea81496de92dbb72f6721871d99748ece67441dd8b7997", size = 17407, upload-time = "2026-09-28T17:48:13.228Z" }, +] + +[[package]] +name = "starlette" +version = "1.8.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e9/0c/6efb252d091ecccd7d62048ae11f0ea35cd75a4fbaeea5e30f9c3bf91d10/starlette-1.8.0.tar.gz", hash = "sha256:1565dc0b35d5737a271ed1e0e04e949f4e81198799f216d2667b0a0fb9cf9522", size = 2730457, upload-time = "2026-10-13T07:54:39.53Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c1/b0/5742e4ac7af5eb58ec3470a537a49d7aa507e5539413e504b3a65ef50ba8/starlette-1.8.0-py3-none-any.whl", hash = "sha256:dfdd6b29c26483288088d990eee59631dedadd66ce20d203402a7ca8e3c4656f", size = 79612, upload-time = "2026-10-13T07:54:38.019Z" }, +] + +[[package]] +name = "sympy" +version = "1.14.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mpmath" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/83/d3/803453b36afefb7c2bb238361cd4ae6125a569b4db67cd9e79846ba2d68c/sympy-1.14.0.tar.gz", hash = "sha256:d3d3fe8df1e5a0b42f0e7bdf50541697dbe7d23746e894990c030e2b05e72517", size = 7793921, upload-time = "2025-04-27T18:05:01.611Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a2/09/77d55d46fd61b4a135c444fc97158ef34a095e5681d0a6c10b75bf356191/sympy-1.14.0-py3-none-any.whl", hash = "sha256:e091cc3e99d2141a0ba2847328f5479b05d94a6635cb96148ccb3f34671bd8f5", size = 6299353, upload-time = "2025-04-27T18:04:59.103Z" }, +] + +[[package]] +name = "threadpoolctl" +version = "3.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/00/dc/6c58154c1c65f758ea979e7139cb76993a9cfc662d14e9be3c4a667cfb77/threadpoolctl-3.7.0.tar.gz", hash = "sha256:61348cfb77d53b9242e0017029244b559b810c142ced65b4e21eeca1843959a7", size = 31961, upload-time = "2026-09-15T15:46:20.263Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/43/3f/f88a53f60a472b46f4023f56d204dd7de33d34c5d2acbfa0d70a674e639e/threadpoolctl-3.7.0-py3-none-any.whl", hash = "sha256:cd8b60b5641b45c67bbf73c64c843235fc2d8a480c87389f52f5dbee893b86be", size = 26362, upload-time = "2026-09-15T15:46:19.168Z" }, +] + +[[package]] +name = "tokenizers" +version = "0.23.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "huggingface-hub" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e0/7c/2cabb2174e772636683008f2c5621949b645da7d303c596589e84516a184/tokenizers-0.23.3.tar.gz", hash = "sha256:cded33237c77caeef62944d32aa9a7ef42bdce2b3497e18d137e072a8c4be438", size = 385286, upload-time = "2026-10-09T10:16:55.759Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/aa/2e/4ce5b9716f26e526eff6b0502ebed4ea8d7161f03b3c77617c9f25528e97/tokenizers-0.23.3-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:9d2b5c97daf61688c2ad1803ca851800feaba50fb68d5821779e9ea5880d968c", size = 3148800, upload-time = "2026-10-09T10:00:51.457Z" }, + { url = "https://files.pythonhosted.org/packages/b2/72/01e49f032bb346e5aaf06c10c74fe8aeec847173adbadd66eb7c53054bf2/tokenizers-0.23.3-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:68649e97d5b43c44c031d8d848874a6eecae8f8fe40ea989aa777a5a83aca716", size = 3101381, upload-time = "2026-10-09T10:00:54.063Z" }, + { url = "https://files.pythonhosted.org/packages/15/fc/ae987741829b1cd547668c4c94be732ae3eefd1d74344e64c3d2ca714acd/tokenizers-0.23.3-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ec82e80e65a862275b97c3d90b7a523df8d9519ee48aeb4e9625b2cc909274e0", size = 3519944, upload-time = "2026-10-09T10:00:55.885Z" }, + { url = "https://files.pythonhosted.org/packages/1c/da/cc8f6c030afaf05fbddc608158fbb761dca46913cbeba6b112e59fc82e2a/tokenizers-0.23.3-cp310-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c64a0713180ff16829d4e7f39a658b77ea11443af4e1aa46523692943c9b1414", size = 3397695, upload-time = "2026-10-09T10:00:57.444Z" }, + { url = "https://files.pythonhosted.org/packages/ec/f1/256f78d1365fa2cd3ea6db716883d74667c8cbb6a21f15fa5b89a773cdc2/tokenizers-0.23.3-cp310-abi3-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ddedfd4b3b4be6be24ff6ca645c4a37fddfd305f6f3e354c54cf10b715c48215", size = 3753125, upload-time = "2026-10-09T10:01:00.165Z" }, + { url = "https://files.pythonhosted.org/packages/60/93/eee007ac2fcbf4ecfce7fbc354826cf3611f56bdb886f3e91b1f7dd06b8f/tokenizers-0.23.3-cp310-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2a89614730d7b80940a5d2ed9320e1ec8add5a745c6151d8d05071b7215505b6", size = 4018598, upload-time = "2026-10-09T10:01:02.05Z" }, + { url = "https://files.pythonhosted.org/packages/bf/f9/0c96c4739461fce9d8d865b416728081bf6230022d7163bd6244f35f4b31/tokenizers-0.23.3-cp310-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e88646b8580c5ad7f4361477f1298e9cc01771a1ee9aecfe32c47b8ff614cc38", size = 3602442, upload-time = "2026-10-09T10:01:03.77Z" }, + { url = "https://files.pythonhosted.org/packages/3a/40/6706b82693715581457c6d5423eaa7faae576bb0526c5738a57085eb4449/tokenizers-0.23.3-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:376851d22bcf9d650a5c3090bb83e6cf9e895fbf0595369fa4cd43c1f69b5f87", size = 3396193, upload-time = "2026-10-09T10:01:05.48Z" }, + { url = "https://files.pythonhosted.org/packages/fe/0c/85946de40e25b7364b8f1bcf56def129069acd5bb364b7c86a32919e1a23/tokenizers-0.23.3-cp310-abi3-manylinux_2_31_riscv64.whl", hash = "sha256:bf501c40b72d2d5c8623620210430e9cac1ce47a46e45b34107b70a1557d46b0", size = 3553483, upload-time = "2026-10-09T10:01:07.387Z" }, + { url = "https://files.pythonhosted.org/packages/f1/6b/8d615d92cad1d511ca5ab188d1c7c167f0b3d295cc0d96207f9f82d486d8/tokenizers-0.23.3-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:114e2b55ed177179d59f4ab98200a4471e11e78f9e4b5a922d146740f96fcf52", size = 9972248, upload-time = "2026-10-09T10:01:09.437Z" }, + { url = "https://files.pythonhosted.org/packages/c9/7d/a922e37ddd58d1b463bbc2ad08120c8f59c60b814cd353519a116b24f8ba/tokenizers-0.23.3-cp310-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:d3407fb7b9c4d75dd68850ffd7180bc0a5d2dbaf0762d888e612f31fec3f9c6b", size = 9802957, upload-time = "2026-10-09T10:01:11.869Z" }, + { url = "https://files.pythonhosted.org/packages/4b/06/5d3f506a86ae0699a0e4ea05c05978f9aee169ef2c1d844e68c971cf8194/tokenizers-0.23.3-cp310-abi3-musllinux_1_2_i686.whl", hash = "sha256:84513ef0aeb8bf8f4ea11a2e8a7ac163ec5288aa115e649a59b470ac5c3107df", size = 10145487, upload-time = "2026-10-09T10:01:14.268Z" }, + { url = "https://files.pythonhosted.org/packages/26/e5/065625317690ea3548d834dad81f48ea1fd32e4964610e658e195d7fe28e/tokenizers-0.23.3-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:e05ab7baf7f47b406a95fea6f3b0a484b2ddcd9e1d14b68844c457eb755085a3", size = 10266026, upload-time = "2026-10-09T10:16:33.054Z" }, + { url = "https://files.pythonhosted.org/packages/77/4e/babede85d0d19f5e3deeef0063e01848141329934d3d77c31b5cab5ac2b4/tokenizers-0.23.3-cp310-abi3-win32.whl", hash = "sha256:1ebf28794e7e4954e20a7f70fbea410b2d1f0418f7dbbca97ca384fcfef38c25", size = 2588086, upload-time = "2026-10-09T10:16:35.686Z" }, + { url = "https://files.pythonhosted.org/packages/d1/6c/24f074c9a0efb98e61b20aafe6b2641922d5db24e447d5d6daffd9e17555/tokenizers-0.23.3-cp310-abi3-win_amd64.whl", hash = "sha256:1f0823bb00c5fdc98e487354d54dd55a03848d61a1a0bf29a68c77f24f3b26c3", size = 2872101, upload-time = "2026-10-09T10:16:37.533Z" }, + { url = "https://files.pythonhosted.org/packages/53/77/a476b6f73a661c11d113a342d2326b91506cf2285f0995d1212a6bb2022d/tokenizers-0.23.3-cp310-abi3-win_arm64.whl", hash = "sha256:7e48734d2de9260d86f03ab056d2cfeeff3869f61dbd49aaa15a2793b5f3458b", size = 2742580, upload-time = "2026-10-09T10:16:39.244Z" }, + { url = "https://files.pythonhosted.org/packages/65/46/f66baaedd42414a3f583c47379dc350e3e1f858a690d2574fd85ae70681b/tokenizers-0.23.3-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:efa3d7318406b4d115dce61ad5061953f1f44b128e79c020ce4615d763e23b6e", size = 3154274, upload-time = "2026-10-09T10:16:40.876Z" }, + { url = "https://files.pythonhosted.org/packages/c6/41/8de8c63b2d935eee5a0f42011fb7b786ffafeab0b8eb6d17acb8af2293b7/tokenizers-0.23.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a4fbb3662f9f59d199d61338e54b4bcc11d07ebbb1aeb3540dacb2be9c521cb7", size = 3077805, upload-time = "2026-10-09T10:16:42.856Z" }, + { url = "https://files.pythonhosted.org/packages/e3/08/b1cbae8dc8fc7c91f992ac2d87a086e9b3f25a28814047ca16a82fe8c87b/tokenizers-0.23.3-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:de536665495cb4b409d25bade41963f801aff4225c19a6b804b048f7d14e34c7", size = 3491678, upload-time = "2026-10-09T10:16:45.093Z" }, + { url = "https://files.pythonhosted.org/packages/3e/0d/aac0cb2f3a1fdbef514145b4c5f2df4d05deeb1ee8f73ae641a1b4a62a85/tokenizers-0.23.3-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5cc24bb457dd4a8af89c8fcb40074d570129ec473df2a866c276ee55db4749d7", size = 3367420, upload-time = "2026-10-09T10:16:47.112Z" }, + { url = "https://files.pythonhosted.org/packages/1e/1d/41a697d0c193a320b243fbd68b2057b6eb2f01ecf80899e1a16e646ff699/tokenizers-0.23.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:acd5c57b4bd3e56e246e2731a3a3a6825a7a7d89b7e3b761ba80bc521710f04b", size = 9945973, upload-time = "2026-10-09T10:16:49.326Z" }, + { url = "https://files.pythonhosted.org/packages/37/e9/b56e619fcd583000a2b1254bb46af8dc6a174d3ba3329f454ad5a95a2be2/tokenizers-0.23.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:82eb480f6f1c21cea3349dec32cf1a6384c6c1e775f00f83b0d51197bc013687", size = 10237491, upload-time = "2026-10-09T10:16:51.943Z" }, + { url = "https://files.pythonhosted.org/packages/6f/68/f58b3beb95f3b62816e91e5e768e684cd63e58f9cbece22036dae3b1c971/tokenizers-0.23.3-cp314-cp314t-win_amd64.whl", hash = "sha256:1554a6eed34d9d6a78d23360f4e06df8dffab1ae08c7e8488e0b3e3b36cc266f", size = 2847654, upload-time = "2026-10-09T10:16:54.166Z" }, +] + +[[package]] +name = "torch" +version = "2.14.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cuda-bindings", marker = "sys_platform == 'linux'" }, + { name = "cuda-toolkit", extra = ["cublas", "cudart", "cufft", "cufile", "cupti", "curand", "cusolver", "cusparse", "nvjitlink", "nvrtc", "nvtx"], marker = "sys_platform == 'linux'" }, + { name = "filelock" }, + { name = "fsspec" }, + { name = "jinja2" }, + { name = "networkx" }, + { name = "nvidia-cudnn-cu13", marker = "sys_platform == 'linux'" }, + { name = "nvidia-cusparselt-cu13", marker = "sys_platform == 'linux'" }, + { name = "nvidia-nccl-cu13", marker = "sys_platform == 'linux'" }, + { name = "nvidia-nvshmem-cu13", marker = "sys_platform == 'linux'" }, + { name = "setuptools" }, + { name = "sympy" }, + { name = "triton", marker = "sys_platform == 'linux'" }, + { name = "typing-extensions" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/4b/3c/379349820e9a6e713826de3d0b88f07728541bdcabe2a3afd128871cdc4b/torch-2.14.1-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:420dbf314c180ee4b86e9bc00aee5746a7d6e5bacdd7df925af671ed393f0b2e", size = 127307099, upload-time = "2026-09-30T17:44:10.421Z" }, + { url = "https://files.pythonhosted.org/packages/a4/3e/474930ab971e306957ec763b7df695d659179e15ccab9e7073d47ae14ce8/torch-2.14.1-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:0b13acde294b401509830d85a8906cbf2fc97425a12a66cce1d9fa13c5106a2a", size = 453998918, upload-time = "2026-09-30T17:52:45.134Z" }, + { url = "https://files.pythonhosted.org/packages/ba/fd/c470857d0dfc5d4c94430658b3eed60fd5292b0bf596a8aae7b9fd66d674/torch-2.14.1-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:23011fe29a99b591eabb3a31a25080c62e2c2f0690a3c798744e5489dae50451", size = 554618695, upload-time = "2026-09-30T17:53:06.8Z" }, + { url = "https://files.pythonhosted.org/packages/ad/1a/b9648b0d228e513f69f8453ec3f90a8d436d78ff6d85a2773e539f0c07cf/torch-2.14.1-cp312-cp312-win_amd64.whl", hash = "sha256:38bee9f2a2ccfc6898172a5075e4ba52522fbe143eeb099674fc67ab390e858d", size = 124113906, upload-time = "2026-09-30T17:52:21.377Z" }, + { url = "https://files.pythonhosted.org/packages/7d/11/faaca4f8541c45127b7e0d6bb141221fe944c8466d89986b8466c6c195f9/torch-2.14.1-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:dbe359d705f4d67236743794c296c6dee93a922fd8117eff8c3e880d7d0fb2b9", size = 127315286, upload-time = "2026-09-30T17:52:27.43Z" }, + { url = "https://files.pythonhosted.org/packages/60/1f/0330275c705b846882531c64e80d32b13fb572eeb9b995e87e84154b0712/torch-2.14.1-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:6d530bd11479fb574567af3a1f384af9a77bf5bb9550cde8080b356ca7220d5a", size = 453999200, upload-time = "2026-09-30T17:53:21.716Z" }, + { url = "https://files.pythonhosted.org/packages/7c/cc/bb579ac0c80e077c58204e43b37a254b74dea5873635100bea25789a55d4/torch-2.14.1-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:c8f71aabc67bcbfc9373dc131537a5968d04edce73e88add21354a7cd0a76985", size = 554618164, upload-time = "2026-09-30T17:53:42.73Z" }, + { url = "https://files.pythonhosted.org/packages/7b/4b/32c00cacbe682a4c08d9c6c3e7a7116c912184cc7155f06c64f545db97d1/torch-2.14.1-cp313-cp313-win_amd64.whl", hash = "sha256:711713391d26a1ce5e9fbc6c996d954a8c12e8825374cc77b809a6af29539b8c", size = 124113754, upload-time = "2026-09-30T17:52:33.474Z" }, + { url = "https://files.pythonhosted.org/packages/43/19/23a1aed488423a5055727256b25406e4b93bd2bcf1352bef582b9951c10c/torch-2.14.1-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:cee091caf2a6229e248daf41d18ceebf590ba02d9179205610062c64ee5fef03", size = 127325330, upload-time = "2026-09-30T17:52:54.752Z" }, + { url = "https://files.pythonhosted.org/packages/93/f4/94219ada13edd62fda1f976163292b7b8595f1fd2f4ef74af24b3baad375/torch-2.14.1-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:f68f5476e2bc0e8f60b74f7ca21acda885c4477af6392f8977e4f0d1ea1aa162", size = 454016297, upload-time = "2026-09-30T17:54:03.078Z" }, + { url = "https://files.pythonhosted.org/packages/fd/df/23c69e9b9fd19fe6563422f1bc59f89601bda47234a89a1cf432cfde3aee/torch-2.14.1-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:1d4df07be7338bbcc4d54085adee21363c91314702b9bd4d78ef72ffac9465ba", size = 554621744, upload-time = "2026-09-30T17:54:22.951Z" }, + { url = "https://files.pythonhosted.org/packages/d9/dc/a36a4431ab5e3ad168a75f341e24677a4bbbf0c5dda59c97a7079262bb94/torch-2.14.1-cp314-cp314-win_amd64.whl", hash = "sha256:d02a4c48a2ca5fb7654e36e71f710f74494d83f1f10aaff8e059dd554adca956", size = 124110781, upload-time = "2026-09-30T17:53:31.141Z" }, + { url = "https://files.pythonhosted.org/packages/2d/bc/1afbd1a22f6023eafcdb9295ac1d97137a7816f54091d693701072da2218/torch-2.14.1-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:06c3ec25f3b497f9a73dc0c36f293f295cfe8d5584fd446238d9a501b30a66d5", size = 127657364, upload-time = "2026-09-30T17:53:53.837Z" }, + { url = "https://files.pythonhosted.org/packages/e4/94/b97e863c9ceef2bc6e082e967cbfb9065b5e232f2a60c5af0f5115a1bc31/torch-2.14.1-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:9113f94f70429f9f302bf55b090b5411269083e8a72a5d015ffcc5a7f83f2c69", size = 453998592, upload-time = "2026-09-30T17:54:38.268Z" }, + { url = "https://files.pythonhosted.org/packages/03/d8/8272157c438cc26a199a8fa0caf87985477b8a1ed8a09084eb2ae40609d8/torch-2.14.1-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:e65d5fe136e533b23c2d134377f7f126721c7c78dd75b3dbb735af082a8aeb85", size = 554578730, upload-time = "2026-09-30T17:54:54.838Z" }, + { url = "https://files.pythonhosted.org/packages/45/05/451a69a4287033d8f106f5c65f92c2c0c37229d81ea101d4b047caf758cc/torch-2.14.1-cp314-cp314t-win_amd64.whl", hash = "sha256:e07306caa1de2a4ac1467e11ecfc92fc44f523dd6a521145039aef46d913963c", size = 124400129, upload-time = "2026-09-30T17:54:12.306Z" }, +] + +[[package]] +name = "tqdm" +version = "4.70.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0d/ea/b2a5bd54b28a324dae8211928b2d730b6547500342c7e6c6dea08bd0a485/tqdm-4.70.1.tar.gz", hash = "sha256:cefd0eca11b2a37a3aee776544d4f4ae913f02688135b5556b8788dfa474afc4", size = 171846, upload-time = "2026-09-11T07:25:16.601Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a7/03/921a3d3c75785aca9ebfbfcabfbc3a1be12e2ab5265deb026d55a5a3f83e/tqdm-4.70.1-py3-none-any.whl", hash = "sha256:c293e525e6fef9c20e8728fd4612df02a0aa31bb5fe91ecd93e123b1b7bffa73", size = 80199, upload-time = "2026-09-11T07:25:14.599Z" }, +] + +[[package]] +name = "transformers" +version = "5.19.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "huggingface-hub" }, + { name = "numpy" }, + { name = "packaging" }, + { name = "pyyaml" }, + { name = "regex" }, + { name = "safetensors" }, + { name = "tokenizers" }, + { name = "tqdm" }, + { name = "typer" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/65/4c/70012ed0795235fb6bd7806fafd4a1842f03e49ebbfa3bb9580e451948ce/transformers-5.19.0.tar.gz", hash = "sha256:87f38dd25e4521151b97e94520ac457f44a0ae8a8358a5b112daff6c64a822d6", size = 10032022, upload-time = "2026-10-06T16:39:00.229Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b9/59/1e7f1212c215c73690ae6cab4678efe1555ff985953e145d88cc212859b0/transformers-5.19.0-py3-none-any.whl", hash = "sha256:afcd2dd5f603ed28c1e1fcb00a338ccbb4ef5f878ed289635df8b58187afb518", size = 12599123, upload-time = "2026-10-06T16:38:56.794Z" }, +] + +[[package]] +name = "triton" +version = "3.8.0" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/87/07/0f8cd8e8db0472334253efdaaab3d0819fea27aa99bf0e7f1aeea4ceb5ae/triton-3.8.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a9c404c69ed4a39e8ec632eaf6b9fe058a060bf98979c177f6ef666f06bb8d50", size = 226474486, upload-time = "2026-08-28T16:08:18.29Z" }, + { url = "https://files.pythonhosted.org/packages/c1/09/b7012e5bfae67640f268aa584caa80fe1674f6b0da949046b679972c33e3/triton-3.8.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e91ffa46d095b252248297292dd22bcbacd53a125a0c2eefbbbf74925a320bc3", size = 247972921, upload-time = "2026-08-28T15:55:53.157Z" }, + { url = "https://files.pythonhosted.org/packages/87/4d/4c564374bcdadb166fccbf3e45aee0d4a473f88d341761bd2fefe3b8e8c1/triton-3.8.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b7004666652f500ed854a86988e4b3d69d247188b5d2092b5df1e44f4a954099", size = 226476793, upload-time = "2026-08-28T16:08:30.956Z" }, + { url = "https://files.pythonhosted.org/packages/b0/b6/3394d5548404c1cabd1dadadd28d0b3f9478db1dff8180da53bb3f0a1e19/triton-3.8.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1f0497218e26b7d79773ad9c2a3fa3b539ee69f587a13fac2e552b1d322a8015", size = 247975122, upload-time = "2026-08-28T15:56:04.112Z" }, + { url = "https://files.pythonhosted.org/packages/b8/59/bf0e9493118bb353ab59a5d6a65db3618d9b314417cc1459f0121e0ec5c9/triton-3.8.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1f6b48d0591929a3867973acac3dccd4e058585f91bfb41022de496c9ffab304", size = 226488654, upload-time = "2026-08-28T16:08:47.141Z" }, + { url = "https://files.pythonhosted.org/packages/93/d9/08c75f3459f19ad00425b564058e40efa4bcd79b816064cf27499303ea42/triton-3.8.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:387dae4cb0089a7b6ba1a428ae0782b65c4c58f57d94617cb22ca8593d8ccbca", size = 247972313, upload-time = "2026-08-28T15:56:14.007Z" }, + { url = "https://files.pythonhosted.org/packages/7c/34/429c5592181cfb7361a0a8e0bff218e7224b726709d75da2472b3e819f70/triton-3.8.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b84e7d512490ba529111260fa6f7cad8b254a6bb5fbdf41d5ef9a5e57f52d0a", size = 226591133, upload-time = "2026-08-28T16:09:02.271Z" }, + { url = "https://files.pythonhosted.org/packages/fe/d1/aa8a3e935c37efee7945984fdb64d7e0851bf6d920afd97b2d21f9d23360/triton-3.8.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:74217bb56ed8692759227758e4c4b3bd2d608a209c1a7a081bf361fb4c2c1bf9", size = 248077577, upload-time = "2026-08-28T15:56:24.94Z" }, +] + +[[package]] +name = "truststore" +version = "0.10.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/9f/c5201d42a484c061e528825fc8e2d565f5abd50a4ced6fb7d29c4ec99b2b/truststore-0.10.5.tar.gz", hash = "sha256:30d36967ccaded5cbb38d602c433f53600036c79d502f4533a49b60a03bbefcd", size = 28091, upload-time = "2026-10-12T22:27:31.808Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/51/e9/3a7820be2bb0fe53b6bc9c3be26d3d1158004e4c3ab953aa6840b955b1e9/truststore-0.10.5-py3-none-any.whl", hash = "sha256:9aaaedaefaf06d8b206278cf8b5012bc897f485a874503501e12d776df78951c", size = 19017, upload-time = "2026-10-12T22:27:30.377Z" }, +] + +[[package]] +name = "typer" +version = "0.27.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-doc" }, + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "rich" }, + { name = "shellingham" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/03/51/d33db42cc72ffd8c30777547b42d01f0cbf9d95a770457698d0174b3ed71/typer-0.27.3.tar.gz", hash = "sha256:d0396f770a560ab1b0a8504e13b5f254b728cedb05c61cf0359e944e50ce8901", size = 205303, upload-time = "2026-10-06T17:24:16.61Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/07/ea/2e31b67051e91a133189e9c000c222502ddc6969856416de0d095de4c0b0/typer-0.27.3-py3-none-any.whl", hash = "sha256:e50022f28b82a86313e54501317a1db64bf8f8d036ff8cfe5ca7e47675454aff", size = 123312, upload-time = "2026-10-06T17:24:15.054Z" }, +] + +[[package]] +name = "types-pyyaml" +version = "6.0.12.20260906" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/90/6e/abec85b9013db5b934b0280a6dd104904d84f7bcbaab2e2f3def87ac7463/types_pyyaml-6.0.12.20260906.tar.gz", hash = "sha256:f59c1cc05010b833d2d72287bbaa72610106b28d42d89a907313117faba85212", size = 18649, upload-time = "2026-09-06T06:35:35.362Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/15/c0/fc0644b7ddcfb969e95845837143cb5173ddd6e06ee4ba5fc493cd9329b7/types_pyyaml-6.0.12.20260906-py3-none-any.whl", hash = "sha256:bca893ff0d51df5c9053137d5d0e6ccd36e939a196356f1d5c16372422f5137b", size = 21282, upload-time = "2026-09-06T06:35:34.372Z" }, +] + +[[package]] +name = "types-regex" +version = "2026.9.29.20261005" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d5/e9/4995560975e0b953b6f114ad655ae529a7dcc11d03fbbc9ab95cf0ebdea2/types_regex-2026.9.29.20261005.tar.gz", hash = "sha256:544b8f5b45e4d10e3d3222c92f8280426588cc98fd34eb1e2c5311f7c68a4189", size = 13907, upload-time = "2026-10-05T07:49:30.328Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/bf/3e900befa0c479d277db0f115c668bec681249a26e6dee44c0520080fa8b/types_regex-2026.9.29.20261005-py3-none-any.whl", hash = "sha256:24a74fa7fdf0c17300a499f49c0b6dc6865851e138d76a24922d220c048fa9d3", size = 11194, upload-time = "2026-10-05T07:49:29.454Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" }, +] + +[[package]] +name = "typing-inspection" +version = "0.4.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a3/26/b09b8010994eccc3c09092e6b34058f36a460eea2d4c3e8b910c695975a0/typing_inspection-0.4.4.tar.gz", hash = "sha256:547274fa6b0a561ccf549cc9524b999a578e737d015d8709d021f9d0d13bea47", size = 76928, upload-time = "2026-08-12T12:37:25.997Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/67/81/4add07e5172b7ac40d8ed5ff580409a7801a4fe26d529bdd915401dabfbe/typing_inspection-0.4.4-py3-none-any.whl", hash = "sha256:65b8397ba37ccbce054456aaccddfc91e6e3083c92824df348d96ca832f3f147", size = 14750, upload-time = "2026-08-12T12:37:24.648Z" }, +] + +[[package]] +name = "uvicorn" +version = "0.54.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/da/34/30e9280707135d2cfc589dfff3cb796bd07a3aeb1a3e415ba09dd89d7bb4/uvicorn-0.54.0.tar.gz", hash = "sha256:a2e33cbfaa0306f8e6b0c13e0cb89d7d7a2da3e62b90c66e18c33d9807b28620", size = 112283, upload-time = "2026-09-25T06:52:37.601Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/38/0c/b54a4fdd7f90a3af8b02ebc9ce6712c2c208b7926a2f7bad95c33ebbe943/uvicorn-0.54.0-py3-none-any.whl", hash = "sha256:505bdb0f318731d45f1f712071fc781a8981f6847a31c902c9f5e652d4f67faf", size = 87427, upload-time = "2026-09-25T06:52:35.829Z" }, +] + +[package.optional-dependencies] +standard = [ + { name = "httptools" }, + { name = "python-dotenv" }, + { name = "pyyaml" }, + { name = "uvloop", marker = "platform_python_implementation != 'PyPy' and sys_platform != 'cygwin' and sys_platform != 'win32'" }, + { name = "watchfiles" }, + { name = "websockets" }, +] + +[[package]] +name = "uvloop" +version = "0.23.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fa/42/02c739ce85fb2ee8d99212c61417da8140c6b87e9d97c430bea520d76044/uvloop-0.23.0.tar.gz", hash = "sha256:28d160f51ab4da3b187063652e643dea6831072add4adc1e6d62afbe73b6be27", size = 2559185, upload-time = "2026-10-01T03:17:04.4Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/05/98/04e766a6de99e6f7f955ecb7829e8d5a557de3427cb85be2236de54dda0c/uvloop-0.23.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:93935ab27b6eaef4c3e5489aebc84284f0644592f7ab516df60ee1b27eaf5eb3", size = 1393055, upload-time = "2026-10-01T03:15:42.526Z" }, + { url = "https://files.pythonhosted.org/packages/33/8a/499e7b863a848ede009539bce39806b66205da5f8779354228e785601144/uvloop-0.23.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:4448e9124537620f9c25d004c227bb5104440b58955c19bbd312d910af919a63", size = 768909, upload-time = "2026-10-01T03:15:43.974Z" }, + { url = "https://files.pythonhosted.org/packages/3d/95/a880f8ce3b87ac5b307c354e8ee480be4658d24bf01f87921d57e3530b4a/uvloop-0.23.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f7548ede3ee908cfabc0d068106e303a9a2d811af959cdf6ab85676344cedcda", size = 4419106, upload-time = "2026-10-01T03:15:45.551Z" }, + { url = "https://files.pythonhosted.org/packages/51/27/c1d2f9fa977f8f42ea294604166df10e0027e6dc6cd17f85ede386c9bf36/uvloop-0.23.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:090865d8ce7a03986755a3ce711b7dd0d4b44eb14ab74368b717f3fad1180208", size = 4532597, upload-time = "2026-10-01T03:15:47.258Z" }, + { url = "https://files.pythonhosted.org/packages/42/dd/2cb6a2c8a30ca55c07a882dd4ae4ceae0fa7d8c15b25b3b7cb9a4b6cf4ca/uvloop-0.23.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:bd6f2f81c7b9da99d301c0b16b82044e76fe887086e42e1590ecf520b94dbdac", size = 4230048, upload-time = "2026-10-01T03:15:49.119Z" }, + { url = "https://files.pythonhosted.org/packages/f4/52/29989cbaa4022dc4ef35c1dd60a4ab989e4c2065f341ed483ae71d2bd950/uvloop-0.23.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a6ac96da66c35bf789bdcde78a88dc7d56b7907d8379648c54adc1c61594575d", size = 4394152, upload-time = "2026-10-01T03:15:50.829Z" }, + { url = "https://files.pythonhosted.org/packages/5f/83/eb980d64e6dd5da46d4dc35755fa6afd6b5b47141437cf89615f1117c5a6/uvloop-0.23.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:2dcff2d69be43e6559e5dad2c5a7a2dbfb60e05a77311b6c4b7a4a8123d86c65", size = 1412726, upload-time = "2026-10-01T03:15:52.49Z" }, + { url = "https://files.pythonhosted.org/packages/04/c1/02a725e7698134c647904bdee6589e2be14a0e7fc9942c74f86e2b90d48b/uvloop-0.23.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:19c64108b507cd0bc140e400e3396bacebd9d504956aa7726272bf6de7d9aabb", size = 779071, upload-time = "2026-10-01T03:15:54.02Z" }, + { url = "https://files.pythonhosted.org/packages/0b/1d/cde53c79e8c01884ad1cdca8e407e086d523362cfe4139e2c2a8dde27304/uvloop-0.23.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1748321e3c59a14a75404b1ae8d5a8d81c4e201803ea0e14c1b6fd84421024b5", size = 4395323, upload-time = "2026-10-01T03:15:55.549Z" }, + { url = "https://files.pythonhosted.org/packages/98/54/b12915bebbf99d7ae0796211e7f5977b95f069830dca45dc1a346d84125d/uvloop-0.23.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e2cba180d6451822763eda8364f342435a873bcfb3849cbd82fdeca248ca65eb", size = 4480449, upload-time = "2026-10-01T03:15:57.362Z" }, + { url = "https://files.pythonhosted.org/packages/f7/8e/da6de68c31549a052a105fc76f5a9a204f6df22cb0909440aa4dbb06f9a2/uvloop-0.23.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:dc61e4f9e37b507069dc7e659ae28bca7adcb04c993c3508214315d12c63f848", size = 4219177, upload-time = "2026-10-01T03:15:59.351Z" }, + { url = "https://files.pythonhosted.org/packages/a1/c3/1b53c6a89dc9c9d5cb75eb9a0b891ad69b32e1421ad3aa01617a9cbdcc78/uvloop-0.23.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:7337b06a9f9ed9ea3049f04b76f65819db9b19bb832ee598e97b388eadf25e5f", size = 4346132, upload-time = "2026-10-01T03:16:01.064Z" }, + { url = "https://files.pythonhosted.org/packages/4e/a4/00e85345871c59c834a23c136c1771205856028ecc8ba940b3951178e59b/uvloop-0.23.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:b90397a50ad6332ed3e459c648ac20d182cce24a557354363ad85fc9ea4a17cd", size = 1421363, upload-time = "2026-10-01T03:16:02.599Z" }, + { url = "https://files.pythonhosted.org/packages/d0/a9/e5f0f3cfde30af3ec32eba8ec07bccdba2b5116afbd1ecc53edfeb0a0790/uvloop-0.23.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:be53e1d5f83de43dc175c87612ecc128d444b38e5c56cb3f807f5a73d6887476", size = 785177, upload-time = "2026-10-01T03:16:04.018Z" }, + { url = "https://files.pythonhosted.org/packages/9e/79/9ddf78f8cd75a15c14a09a57f59c587b8cd9d82802c5c8368b9c3ebefa0b/uvloop-0.23.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b3cbc4f96ddfa1fb88a78a69dd851369825b7816d9702eee8c4461505ba172e", size = 4381060, upload-time = "2026-10-01T03:16:05.642Z" }, + { url = "https://files.pythonhosted.org/packages/1e/20/57d63c44d32326878fcad5c63854afc9deb394ed95673c1b1a429178c79d/uvloop-0.23.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:31e0cf90bc8fd88784f6802cdba968a51fb1aec1cc3feec74d862b2d371d1330", size = 4418891, upload-time = "2026-10-01T03:16:07.326Z" }, + { url = "https://files.pythonhosted.org/packages/12/c5/0795abecda2cc3dfe41033f880a32a9ff103be4e6b177ac736833c153a0e/uvloop-0.23.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fa8ed556fcc87a4091cf61587ef172fa104323dc89ecc085a618ba7ff8629a8f", size = 4214811, upload-time = "2026-10-01T03:16:09.13Z" }, + { url = "https://files.pythonhosted.org/packages/20/18/9010dacd5221eec1bd79a4a83ac68f3db6a42d7bb657f7b640c4838ca6b6/uvloop-0.23.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f3fbfe82829d8e381426a289b87e59e585278728361db9ce975b88b51f64f410", size = 4294876, upload-time = "2026-10-01T03:16:10.875Z" }, + { url = "https://files.pythonhosted.org/packages/b1/08/f6384a03c771d00067cba4f542a69b2fc1a982e9fd78b357c2f788678d72/uvloop-0.23.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:7e35c9bc977760981693e1a7a51493b58ee5a501f9ebb1e547565ee40b6c6208", size = 1494811, upload-time = "2026-10-01T03:16:12.399Z" }, + { url = "https://files.pythonhosted.org/packages/ac/01/756a4fb24a449f313cf4a153eb0c6210b49cfe5539255ec9fb1e17d2c4ef/uvloop-0.23.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:5bb9be71d9ee39b4359b832f9569518ec9bc08704194034e79e4958e6bc4d46d", size = 819396, upload-time = "2026-10-01T03:16:14.094Z" }, + { url = "https://files.pythonhosted.org/packages/3e/45/e314b0c600b14f53dad3a3c2d7a922a249a88225fd727652b53e1854b9dd/uvloop-0.23.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1e84575f11873c109cf3962ad0bdf679094466184125f4cadcc41a73febff41f", size = 4734966, upload-time = "2026-10-01T03:16:15.815Z" }, + { url = "https://files.pythonhosted.org/packages/66/0d/8686a7f0b1b2d55ebd770ba21f8e0e4ffa0cde5ab738f43ffb8264499052/uvloop-0.23.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bbbdb8fcd5e7062e546eec1ac78c28bb21ae7df54c18f8e4b06e15a18d661a49", size = 4584963, upload-time = "2026-10-01T03:16:18.198Z" }, + { url = "https://files.pythonhosted.org/packages/78/b2/034a2d47e435ac02357c42956246887167bdc0357bdd6ad31c5f6d94497b/uvloop-0.23.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:76345f51367fb1f23e08605c6efb18374f669be5b223658fbab6b17627950507", size = 4421388, upload-time = "2026-10-01T03:16:19.953Z" }, + { url = "https://files.pythonhosted.org/packages/f0/77/131f4b583e6b4b715c404a66b51c812d701db20f25c9018b188a2b00062c/uvloop-0.23.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c7ef4701a96553514b2688e342ef1bf2beae6cfd172d89a76c768292aabf405", size = 4402414, upload-time = "2026-10-01T03:16:21.716Z" }, +] + +[[package]] +name = "watchfiles" +version = "1.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b3/68/e6aa0b77d217b31f8f486ec0cdfe5e00e6e38dc0be657e7d85819b9faf0a/watchfiles-1.3.0.tar.gz", hash = "sha256:99aee4a07847c06820765fd7b1b49ceac4f3f711ccb7d104655a33231de1c207", size = 108119, upload-time = "2026-09-21T09:08:55.664Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/68/fa/c0b840d5d8bafe640925408ce11095948c8945bb61be1e67d0ab629b872a/watchfiles-1.3.0-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:000b9688fc8133037a8b075c8ebf98f32844ff8964dda61e85c1db547dafc441", size = 399703, upload-time = "2026-09-21T09:08:13.518Z" }, + { url = "https://files.pythonhosted.org/packages/c7/8a/894799b485fe9473ad10422a0d9668e53fb78e3a2b5cc6061159572844ad/watchfiles-1.3.0-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:bbc1198edfdc90fda0600f825aa94150f428dfcbf8138746f55998e0e660d64c", size = 397647, upload-time = "2026-09-21T09:08:15.16Z" }, + { url = "https://files.pythonhosted.org/packages/5a/61/f277cd5ead05f5d37b98b41fbdd0c943ee0c04d49738f62e902cb291cf43/watchfiles-1.3.0-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4af9c464d410e8c44b58ddf1cbec7c3a02c1cb2c6c80df9c74659ba2fbda0ab5", size = 454994, upload-time = "2026-09-21T09:08:16.587Z" }, + { url = "https://files.pythonhosted.org/packages/46/66/ea4c01382975e53b1ac1b0a0ea1b353416b8127fb6e04f5c1c313119aab6/watchfiles-1.3.0-cp310-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:eb94e40b9a0db19636b5e3fed0a9803452ecc7381df13b385272abb779ee83ca", size = 460105, upload-time = "2026-09-21T09:08:17.961Z" }, + { url = "https://files.pythonhosted.org/packages/31/c3/1a501b597817096c9831c255f6270c124ce0e2faeb74124be24dcc93b86d/watchfiles-1.3.0-cp310-abi3-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7398930a2b76b9dc67a4e6b8bfc7baf30946892a86f417a3920c07bdd6febc30", size = 493761, upload-time = "2026-09-21T09:08:19.327Z" }, + { url = "https://files.pythonhosted.org/packages/5a/16/77352d05e152c7786a016e9f1decdb4507e8aa5bcf3343c72142a0413d14/watchfiles-1.3.0-cp310-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:025b108f2d5cc2799cb941f79380df3e8b22bfb589c218e801767fe78d598c95", size = 577500, upload-time = "2026-09-21T09:08:20.723Z" }, + { url = "https://files.pythonhosted.org/packages/95/fe/81001c55b24466fe9911135e50269fd3a29f43e446627a92adfab54553b6/watchfiles-1.3.0-cp310-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:be9ef3cd403d756a304a0a08a112163c63da7ec72c8c9d083c98818291ff8f29", size = 469209, upload-time = "2026-09-21T09:08:22.346Z" }, + { url = "https://files.pythonhosted.org/packages/8a/cf/e3ad894ed5909a7c44b0707e89bc25d19a3d78b3a7aec846119443b6ae56/watchfiles-1.3.0-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b5768b49e426fd5b550b012c866db347cdf15c398ef98dc557b6e6b72fa74cd1", size = 458353, upload-time = "2026-09-21T09:08:23.7Z" }, + { url = "https://files.pythonhosted.org/packages/7f/f5/2f106829e6e2bc91ad74614730f6df484202b9a4d20d927dbf2f08d37d44/watchfiles-1.3.0-cp310-abi3-manylinux_2_31_riscv64.whl", hash = "sha256:d36c72fcdc08f143d87316a4c4c3b064e340da2c7383d39803950740ed418935", size = 468707, upload-time = "2026-09-21T09:08:25.167Z" }, + { url = "https://files.pythonhosted.org/packages/15/37/2741ab776b978669c1f3bba1e573f1a184f727ab8b4d3a6d9c61090800ed/watchfiles-1.3.0-cp310-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:f0c9865240e065a2247f4b5248528ae3d01e0e8ae071462c250c884dc16e3ee3", size = 632510, upload-time = "2026-09-21T09:08:26.532Z" }, + { url = "https://files.pythonhosted.org/packages/48/64/db4a4684275fe9b696ee5118c194705c1423b3d42181c3f8ee70194d5c73/watchfiles-1.3.0-cp310-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:380f513d26cc2e598266b88d66456e07d67ef4be8f0f435a1154d9c44b43d509", size = 661723, upload-time = "2026-09-21T09:08:28.004Z" }, + { url = "https://files.pythonhosted.org/packages/52/80/95ddb24f6a2f595c031c24432ab7b8aa7d6d1ada0c6d63a552166e06e8d1/watchfiles-1.3.0-cp310-abi3-win32.whl", hash = "sha256:509d9f74d2bec5c1f4cd868ddcfe0c9601f7ea53066dfcb19d8ede742c4de661", size = 277761, upload-time = "2026-09-21T09:08:29.335Z" }, + { url = "https://files.pythonhosted.org/packages/48/a1/1c4a1b3c8030cd03206232612be8a6e8799093490c220b0fcb935d02b75a/watchfiles-1.3.0-cp310-abi3-win_amd64.whl", hash = "sha256:1acabde19b67e673274e89a04d613b7fb1f122e2c4b8ec9a581e59c149264b61", size = 290643, upload-time = "2026-09-21T09:08:30.45Z" }, + { url = "https://files.pythonhosted.org/packages/82/e0/d2db577c632d26d08078c4390dd870b8555c4e558df25d22270f0166c71f/watchfiles-1.3.0-cp310-abi3-win_arm64.whl", hash = "sha256:d978cc1dd7ba44f5590d7de74f7a9c8be7262d9e469c2c0efb8bbfc2574321dc", size = 285510, upload-time = "2026-09-21T09:08:31.561Z" }, + { url = "https://files.pythonhosted.org/packages/4f/55/a122743607939429ab6a7272672f49303aa02bbd34c3f06676765a3861de/watchfiles-1.3.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:e0204f90677b7fb8d3a824279a71a01166bea9f6e53923b8056592d87b33b0b6", size = 395978, upload-time = "2026-09-21T09:08:32.747Z" }, + { url = "https://files.pythonhosted.org/packages/3e/9d/0ee1b0ea42acfe72afa2484f0222e2386dd09992e9034a81e63cc3cdab0a/watchfiles-1.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7c1125e99f84934a92996ae343501285617373acd66f16b59fd5654b27ad8d80", size = 387997, upload-time = "2026-09-21T09:08:33.953Z" }, + { url = "https://files.pythonhosted.org/packages/b7/f2/4319c6b683e8bd28465dfc53c750617a2a07f072eb2e3ab0182ccf812229/watchfiles-1.3.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a85e243c87109d9f48a2e0d10d6abdee8768cc114848b32281ec83d7f81d86ef", size = 448067, upload-time = "2026-09-21T09:08:35.104Z" }, + { url = "https://files.pythonhosted.org/packages/b7/71/9f37ac192a51a24de3a2d7fe9aac1e99b2b2668300674ff7a9f85d95beb1/watchfiles-1.3.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:dd538c71766c59c732e2c5cedc39323c99c11897b99f93eac773438e54af206a", size = 452881, upload-time = "2026-09-21T09:08:36.238Z" }, + { url = "https://files.pythonhosted.org/packages/34/07/1224667c32a4a96876ddd642da69733cbfe6a15897db0865aec5a882cebc/watchfiles-1.3.0-cp314-cp314t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:da213822ec9082b62cadf3008e07a8c690ec7a3c89f6dc5e55a8cf07b56a1b26", size = 484802, upload-time = "2026-09-21T09:08:37.717Z" }, + { url = "https://files.pythonhosted.org/packages/bd/4d/7f29cd87e1f826df86fc0418d0b8f370ca0311c29c618406dc492aad9951/watchfiles-1.3.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d4593392e87669836670f87d62fc754059eaeb0e6157af44060a46558cebe5a3", size = 567522, upload-time = "2026-09-21T09:08:38.94Z" }, + { url = "https://files.pythonhosted.org/packages/e3/ac/3aa1f84abd4dbbfbae99a817dd931f77b439261c911189cad4c30fdfd470/watchfiles-1.3.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:26b81bc515a0f03f66a69a6fea86eea54c8eca4173c46dd7222569f79dd2f977", size = 461997, upload-time = "2026-09-21T09:08:40.142Z" }, + { url = "https://files.pythonhosted.org/packages/82/1c/27c66764d5b0dbb70a36770a32eca217eba739c28b24e8583a035087a3d7/watchfiles-1.3.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ca39661934749df580d89f3dde266f2af7c6968f4c21c72ce2dc387d299e6aa7", size = 451856, upload-time = "2026-09-21T09:08:41.305Z" }, + { url = "https://files.pythonhosted.org/packages/1e/b3/a45827084d953c1d0dd3d75abf85dcac26a0f5d115ad2fe0b7e9e931573b/watchfiles-1.3.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:04c5500faa725d69a99b0f63750e80795e455bb951849e9c98a811b4324997c4", size = 459889, upload-time = "2026-09-21T09:08:42.58Z" }, + { url = "https://files.pythonhosted.org/packages/7a/3d/07740d23ce2bb23419bb24055c435a9abe5fe03efdaea1ba0720b831aab6/watchfiles-1.3.0-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:ec3cd4bb181a7b6329134204c2277c7144ab5f62cce9c5335d6f901ee7ce8d6a", size = 624482, upload-time = "2026-09-21T09:08:44.064Z" }, + { url = "https://files.pythonhosted.org/packages/91/46/9540f8123eaec86df989f26d7704cad0448d9fb7271763a4f8f9fe23617d/watchfiles-1.3.0-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:e5871d4a7f788a9f64fb06f5793389d29d171e5c32d37c3ebfd520b017cc7694", size = 653724, upload-time = "2026-09-21T09:08:45.257Z" }, + { url = "https://files.pythonhosted.org/packages/25/9d/b9deb52f1e4528ffe44338c5ebab95c32ed79203082da661de50288e130a/watchfiles-1.3.0-cp314-cp314t-win32.whl", hash = "sha256:81c989d7267bfb7b676cd32c83bec97914e396870983cbf493e29415037bff5e", size = 270863, upload-time = "2026-09-21T09:08:46.518Z" }, + { url = "https://files.pythonhosted.org/packages/59/59/8b1881afccbafda06248be4185e28034dd73c80c85e4da24f2cb0c1321eb/watchfiles-1.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d754e049006e81a9be49dc78ba7802081cee426e968cbc63ee19e164fa2d0e39", size = 286879, upload-time = "2026-09-21T09:08:47.874Z" }, + { url = "https://files.pythonhosted.org/packages/46/dd/41c12ae9745d5d72f709333287679d71cbba68ad7678fed872207bba94f4/watchfiles-1.3.0-cp314-cp314t-win_arm64.whl", hash = "sha256:a5b631db08fd3032db57ec6a92d1777cd60f88803287e4e941a4858c53aab1e0", size = 276924, upload-time = "2026-09-21T09:08:48.976Z" }, +] + +[[package]] +name = "websockets" +version = "17.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/89/3f825ab71c242fffb62ea8fe638741c290f62f8d7aadf8125ff897747af3/websockets-17.2.tar.gz", hash = "sha256:36c2fb94c990cc2545143b12690e2de6c16300f9dbe5b4f33fa300cf57dc8792", size = 188355, upload-time = "2026-10-03T14:56:53.5Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bc/de/87854af9b38fe4738fd85f7f21c5b49558ae20aec898880894e435f33375/websockets-17.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:916ebdfd82e7fc68041d36b2b5f60361b9abce1e087454da15f8bd004839e090", size = 217757, upload-time = "2026-10-03T14:53:23.029Z" }, + { url = "https://files.pythonhosted.org/packages/3a/2e/1e80b5efa41544f626d56bd15ccb53dbfc56bf28bf80ab9cd6f82c4b1d20/websockets-17.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:3621f3686397708b8eeabfd0a9d75267c1f29a7537d2fe31e65d099e71587fa4", size = 215439, upload-time = "2026-10-03T14:53:24.531Z" }, + { url = "https://files.pythonhosted.org/packages/3b/6e/82c78b595aee05be76a7ee78539323da1593c1848e4fef51c704c696568f/websockets-17.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:a81e19710d48da88653473b6b9c366d47e99fe4f58e37ce415be47966748f31f", size = 215703, upload-time = "2026-10-03T14:53:26.226Z" }, + { url = "https://files.pythonhosted.org/packages/f8/c4/905ef6aa80423c03dba99e1e26fc0acf63a2a9a6a2d9e8c0e6a63caaf952/websockets-17.2-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:f2731f9067976c8c4127212c0d2f2ada42d497d935e470419e029802365b12bb", size = 225023, upload-time = "2026-10-03T14:53:27.744Z" }, + { url = "https://files.pythonhosted.org/packages/03/c0/a6d8be9c43e4456fb9597fdf8b5e0ce1f0a5df41503acce6d869536e4e23/websockets-17.2-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:6627b913b8586b1c06db9516b31dd0dfbc621de3bb9312616d92a7e44f268a5b", size = 225299, upload-time = "2026-10-03T14:53:29.171Z" }, + { url = "https://files.pythonhosted.org/packages/2f/d4/976d34b5491258b0a86c2ce9b9aabb9fdd68919ffd7fe65999c14a502a98/websockets-17.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0198c4ec6a3406a2f7557c032967de426474c2c995c81076585e09d29a9f407b", size = 226540, upload-time = "2026-10-03T14:53:31.635Z" }, + { url = "https://files.pythonhosted.org/packages/83/2f/c4cfd42f53c697a8ed123fd82b8f85fcd13b6360d47f9f1d1d45d6ec6627/websockets-17.2-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:88c6a42c2632ff469e84155e44f6ed92cb15ccb047bf5fcb59225ae5a12fd33d", size = 229371, upload-time = "2026-10-03T14:53:33.061Z" }, + { url = "https://files.pythonhosted.org/packages/e7/55/9a221b29c6232ff9282eecb2fc102402cb9e42a3479264db0e5fc4fe6835/websockets-17.2-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:eb0023e6cdb4b8ece0b33875188dd16104ad8c335361d396a98394f99e30ff7a", size = 227173, upload-time = "2026-10-03T14:53:34.502Z" }, + { url = "https://files.pythonhosted.org/packages/8f/07/125e6d010c56c253d3d2b93cabaea0f96d33898151a16b49066a594acecf/websockets-17.2-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c1c09d5d4646eb96bda2cfb97493bcea21a0956a981de116e6b1f4a9de07f3fd", size = 225929, upload-time = "2026-10-03T14:53:36.071Z" }, + { url = "https://files.pythonhosted.org/packages/23/a8/aad3bd902aee84e1b261ad6ab83b405e4a564af43101b8ad1dc0293ff4f4/websockets-17.2-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0360c4dc13ac569cc245e0efa2f4d4b1e4733d24c47b8ab3f3747227b1356348", size = 223167, upload-time = "2026-10-03T14:53:37.528Z" }, + { url = "https://files.pythonhosted.org/packages/1f/f4/ec8ab9be1a5310b4fea829f088c7aa2b7a58b61d34bce1b2a9338635ff12/websockets-17.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:76693a16dead737946b651375ee3109d7db7ad9569a1c55c60aaed3ef85cfcc6", size = 225974, upload-time = "2026-10-03T14:53:38.959Z" }, + { url = "https://files.pythonhosted.org/packages/65/45/ba6503f8257d3f98b0f07ebaad0fd099c9023eae744fd5b775416743597e/websockets-17.2-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:77a42cc507993ec5471b5283f7eef869239173b6000031543e3938a86d1af0fd", size = 224581, upload-time = "2026-10-03T14:53:40.496Z" }, + { url = "https://files.pythonhosted.org/packages/d0/45/05cca59a876c6776727d96fc7ba59e0b6f9aa496afbf13e7e04ad0b63678/websockets-17.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:3bbc5543e39ee025d524077c5c15c2d67bc11c9f6676afe5b531839e24d701f6", size = 225347, upload-time = "2026-10-03T14:53:42.061Z" }, + { url = "https://files.pythonhosted.org/packages/1c/00/cf0e43292ae949b13f67535be84317102891d69fd1986ec2bf2ead42747b/websockets-17.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:8da58558bfb0ca6ccac2419773521f1111e40654038b1afabdfc69c02cb82614", size = 226457, upload-time = "2026-10-03T14:53:43.575Z" }, + { url = "https://files.pythonhosted.org/packages/79/0d/9a5c61a18f0cc9876d94c70ccb3daf7614a9fee56abbb37c0e64e757fb96/websockets-17.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:01420cb1cb47433e8e7075d32cb8017ad3ffed0654bd1e48c0251b865920dec3", size = 224011, upload-time = "2026-10-03T14:53:45.077Z" }, + { url = "https://files.pythonhosted.org/packages/34/ed/991c1ab80ab2ce40e1c939fef6fa8f971c3ef3b21caf988a7a107e0ad27d/websockets-17.2-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:c49c9edd47d0e44d360299e2d8865e2950d2fcf1b4098782c9d7dcd070919e5a", size = 224990, upload-time = "2026-10-03T14:53:46.8Z" }, + { url = "https://files.pythonhosted.org/packages/e7/7a/363c835d17923e967fb66376188e67b9a261c85d826a0cd5e4dd3471221d/websockets-17.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:96f6c8d0fe21930d1f982bfce2382789d2e8d005d2ab63d21280660f95ef8fe1", size = 225265, upload-time = "2026-10-03T14:53:48.382Z" }, + { url = "https://files.pythonhosted.org/packages/c8/90/6c51f6d78636bd1cd6781fae8ea5ea7bf1d5b4059354f3c1f5f8de793338/websockets-17.2-cp312-cp312-win32.whl", hash = "sha256:b25659ab2d655d742701487d5591e3f98e8f8b329fc999e05e3d59691ab344a1", size = 218228, upload-time = "2026-10-03T14:53:49.867Z" }, + { url = "https://files.pythonhosted.org/packages/c6/2a/90008411c652dcfae34345a2169f4becd066a4ba71eebfa8dd801e0445e1/websockets-17.2-cp312-cp312-win_amd64.whl", hash = "sha256:faa763b677e96f1beccc6b4d7e8c079dfeed2f249f57a19debc321b519ee64ec", size = 218528, upload-time = "2026-10-03T14:53:51.486Z" }, + { url = "https://files.pythonhosted.org/packages/1f/a1/b8ad6c17f8e75ba2215422fffe0d7f0c4b690dcff1c47c0473db0d253d51/websockets-17.2-cp312-cp312-win_arm64.whl", hash = "sha256:63499fc49efe48bccc2fca40723bc7adb198866cbe159093dd979905316994b6", size = 218457, upload-time = "2026-10-03T14:53:52.938Z" }, + { url = "https://files.pythonhosted.org/packages/54/54/a935a32dbc2e7365b1b59eb74b5ab7515456f02370fdca4c4efc3574e96f/websockets-17.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:b24b83fbb34b2d8de06cf0f0d4bd7737344ef854482a614826d4356c0c3f0c12", size = 217752, upload-time = "2026-10-03T14:53:54.59Z" }, + { url = "https://files.pythonhosted.org/packages/cd/95/cb8881851abe2662730e6c61cc521b4c96513fdf9103a44f169afce2eba8/websockets-17.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8a829db795e3f87053904493d184b185c8eb1f497c852f434168ec856aa6f997", size = 215436, upload-time = "2026-10-03T14:53:56.034Z" }, + { url = "https://files.pythonhosted.org/packages/ca/1e/621bb93f35ab7d337be98f1958294437527e2a1797089b5e734ddc5eec5f/websockets-17.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:cf8811d285acc91216368df7fb55cc8c9bf6fcd90eea42429c7186c7385a12b9", size = 215690, upload-time = "2026-10-03T14:53:57.587Z" }, + { url = "https://files.pythonhosted.org/packages/62/4a/49d0c983c082676d5d413b28e6ba5ae1d174c00268467bf78d9fe986a2d2/websockets-17.2-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:89c4898da776193577279173dcf9860487590611d7320d379435a145881b048d", size = 225080, upload-time = "2026-10-03T14:53:59.081Z" }, + { url = "https://files.pythonhosted.org/packages/04/13/95a45eb410019772002d8f53d81396dad4120f7df39ca9962f86f5d7cd01/websockets-17.2-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:d87091c4347daadbcc0833b65812ff38d7350c67339625d4e4a512cf38e3e8ef", size = 225361, upload-time = "2026-10-03T14:54:00.61Z" }, + { url = "https://files.pythonhosted.org/packages/f8/fe/0f0eda80bb441f54becdaf793eb20ee080926f8d2356388377cf262187e5/websockets-17.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1110fbfd530c447380e6e6db88b7e43ffe33d54178f5b0ff0aaa5a280301e668", size = 226602, upload-time = "2026-10-03T14:54:02.098Z" }, + { url = "https://files.pythonhosted.org/packages/5c/36/067fc09d8e6f154abde7c2f747c52cc442a02c5eb14816f5c39cb9f8bcc6/websockets-17.2-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:83abd8beab056aa77a116364811f8fc262dffbcc7abea48de0c85ccbfc6f1428", size = 228035, upload-time = "2026-10-03T14:54:03.545Z" }, + { url = "https://files.pythonhosted.org/packages/4f/a2/939bade7a396b4c381aebbf3941969f124d0f98d56753f81cd256f3fc4d6/websockets-17.2-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:876da8ca5520d65b5d0f2ca6b4e7a00d35bb90ccda35cb2ce3cda4b6c711e84a", size = 227227, upload-time = "2026-10-03T14:54:05.045Z" }, + { url = "https://files.pythonhosted.org/packages/e5/8a/37b1033e21709dd7fa39239ea4d9cd7f348ad5bcba94eb47253878576f8a/websockets-17.2-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8462395df8f224d2daa3d80db3ae4450d9d4b7243c8483ac79a82862f1599dd6", size = 225985, upload-time = "2026-10-03T14:54:06.81Z" }, + { url = "https://files.pythonhosted.org/packages/a0/3a/0d89539900b06d86366facb7558198046de125ab8c371d9248d6262da70d/websockets-17.2-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6e9a04e69456015e6ae5e0d486d995137fd435794442122b00ce5f9526ea3ba8", size = 223226, upload-time = "2026-10-03T14:54:08.583Z" }, + { url = "https://files.pythonhosted.org/packages/31/9a/bfc5633e3d538d0a71cfbe7a5fee56c712e16c2dbd0ce17c83196a2a96a9/websockets-17.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:8a2321bcb73758c44c8076509024d02c15ee484fe77ce04edea4bf4d257492cc", size = 226042, upload-time = "2026-10-03T14:54:10.254Z" }, + { url = "https://files.pythonhosted.org/packages/bb/1f/cbaf1786d8e3aeafe9d76951fc01139ec353b92555580336f23669382a55/websockets-17.2-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:8be4a87b3baca380ec3c7b1643b2dd268ac9d42c5097c0e8dc9a49342faf4774", size = 224639, upload-time = "2026-10-03T14:54:11.911Z" }, + { url = "https://files.pythonhosted.org/packages/80/49/175faa5bd169486f835602ac0ae6303318aa65693b79cdc72c5ee53b148d/websockets-17.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:eb7b737ce8d18c8a08beb68f751572b7bf6a18093ecd1406ca1256b50592552e", size = 225407, upload-time = "2026-10-03T14:54:13.489Z" }, + { url = "https://files.pythonhosted.org/packages/ac/d1/3662f612456cfb2dcc128c8e596f0a55fb7b695025e2ebe8ba2abb355c3b/websockets-17.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:d6605630c2808b33f362d6d08582e79821f77ed2bd3f49f9d467ea70defea06d", size = 226513, upload-time = "2026-10-03T14:54:15.046Z" }, + { url = "https://files.pythonhosted.org/packages/73/6b/07af5177a49e30156b0922556fa93624a920a2b17d3e63bf4ad94668112c/websockets-17.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:dd9252828073fd0d69e7667af4275a1b17c18d0833b1ab7f59db272f194a6b9a", size = 224072, upload-time = "2026-10-03T14:54:16.574Z" }, + { url = "https://files.pythonhosted.org/packages/eb/34/d18054ff4d8314524164f8b8efec2cb17627287e099f122c28ed6fa598e0/websockets-17.2-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:06c7386128a9d85de4e1960114604f3031c084d2f4eee8db382637f1634cbab1", size = 225022, upload-time = "2026-10-03T14:54:18.143Z" }, + { url = "https://files.pythonhosted.org/packages/e9/12/75433caa3e9fa3e51d7751dc6bad24a86addf76cbfb51e52b11d037ba7fd/websockets-17.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:98f2d03df74977fd252831c997c388cd6c3f691a8a9d022b266d3cbd9849838f", size = 225303, upload-time = "2026-10-03T14:54:19.679Z" }, + { url = "https://files.pythonhosted.org/packages/6f/de/23e21c002aa2786ac9807c0876faa3b2576493b29ca3386287b0db46f021/websockets-17.2-cp313-cp313-win32.whl", hash = "sha256:5b43a1f7e4853ce08c3f6d3bf69799ee5b46548bfb71792a8158f7e45d66b547", size = 218219, upload-time = "2026-10-03T14:54:21.232Z" }, + { url = "https://files.pythonhosted.org/packages/13/eb/960411c0c574535d629c16e96a2b4e5353dbe4109df8ecea859e1b5245ee/websockets-17.2-cp313-cp313-win_amd64.whl", hash = "sha256:27c7a59b5352a8f741b422820adfe89dfe47c8f2d84fb32111e76111edaa0e83", size = 218531, upload-time = "2026-10-03T14:54:23.025Z" }, + { url = "https://files.pythonhosted.org/packages/a0/1a/3ac07bb52378952eff1d52d04a7ee6e82ce84e3da319a52a4739cd9c78f5/websockets-17.2-cp313-cp313-win_arm64.whl", hash = "sha256:533b7c82bb1eafbeb921dfe131c9f88e55451ddc328d84bde1c9340ba72d2808", size = 218466, upload-time = "2026-10-03T14:54:24.857Z" }, + { url = "https://files.pythonhosted.org/packages/8b/74/6bc991a28ac983600e65de408ebd1b1413d554ed0468ae5c831bc52dded6/websockets-17.2-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:ecb748910e9ba4624ebe2057791df51dcbffb48c37108ab94a3c593472023c9e", size = 217791, upload-time = "2026-10-03T14:54:26.381Z" }, + { url = "https://files.pythonhosted.org/packages/cb/2f/158e99426be6e71d09520bae53f29294fbb614b2fc5fbf8867b1d08395a7/websockets-17.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:2ab9af5cb7265899e659f079eb71691375a1025b6d5fbd3caa495dd08f70833a", size = 215486, upload-time = "2026-10-03T14:54:27.962Z" }, + { url = "https://files.pythonhosted.org/packages/5c/09/1abf942723c0001d9c2fca1551907dade6304517b982b0bf10bba107fa81/websockets-17.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:06e46da092bca3a52e98f0458c66b247993ce501a07cd09c858be3296511ab7d", size = 215699, upload-time = "2026-10-03T14:54:29.523Z" }, + { url = "https://files.pythonhosted.org/packages/a7/1d/1ade03963ef497c47e6bad79e24370827b2fe6145fa8f58070ff2b7dcbac/websockets-17.2-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:fcce735ffd72ac4056db05325d9f0232382b74826f0196eb6a15ca903abdaa0f", size = 225081, upload-time = "2026-10-03T14:54:31.278Z" }, + { url = "https://files.pythonhosted.org/packages/9f/fd/47b8a0361c49da939b976a07b27a72a9f893d01dfcf4d2a28b53419ce1ef/websockets-17.2-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:42cbca10f82a8b2fb1536e8a0830ca6ceeb6bb3d8d64b766e0795369135654a8", size = 225430, upload-time = "2026-10-03T14:54:32.917Z" }, + { url = "https://files.pythonhosted.org/packages/f0/26/f4d4c76264ee037c5556ab5f50fcba302746dabf7528955534e4dda9965e/websockets-17.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c63ff5a21f26bd0e6a8464b53fadbe174825c8718ac14180df45665eaacdb6af", size = 226676, upload-time = "2026-10-03T14:54:34.833Z" }, + { url = "https://files.pythonhosted.org/packages/37/b3/c8b1c981322a050c4babfd327ffc9880f9c3834f5b15d2574e37eeb8768c/websockets-17.2-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:63f543463601c1558b755f8dd7618b6ec3dd0934dda051d3b7030d8c76e54de2", size = 228048, upload-time = "2026-10-03T14:54:36.424Z" }, + { url = "https://files.pythonhosted.org/packages/f0/5a/1cb29ddb23e6bc27ffd1c5316cd3616360d1ba0c3854eaa134ee3207bd28/websockets-17.2-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4c32eb565ad9ce8a6444248e5b7a19dbb86a81c811fe5fcc2fba7a735aed5163", size = 227281, upload-time = "2026-10-03T14:54:38.01Z" }, + { url = "https://files.pythonhosted.org/packages/ba/64/135274572dc0c845fc1111e2b932c807c395daac75d6eae6cfa148d8a208/websockets-17.2-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5d459bbb6c22f26dcebea56924a362aba50d453b9867912862c970434fcf0d94", size = 226025, upload-time = "2026-10-03T14:54:39.613Z" }, + { url = "https://files.pythonhosted.org/packages/58/75/f1e386aec3124489411caf5138cdd5a2bc43d3fd4a681c69adcf5f6272a5/websockets-17.2-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f19ca1a21871f024e38faf4107b433047df27558dff1b72a1dac31481e2c1fe5", size = 223277, upload-time = "2026-10-03T14:54:41.165Z" }, + { url = "https://files.pythonhosted.org/packages/60/eb/24733a0f568c2eb99e60f9faa620a98fb228c06a01e7e2f348b33290ed9c/websockets-17.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c76b4bcbf0f713194591673fc86a42820e14da6bbd1bb445d3d002cc4d1e4521", size = 226148, upload-time = "2026-10-03T14:54:42.779Z" }, + { url = "https://files.pythonhosted.org/packages/55/6d/ea66a30af74f5983cae31ebb9ef78b178b366a12856a414e1472225c4a34/websockets-17.2-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:30201a7f69833b015556c72feb69ea501b645986fd0b90dab13f589e995ff428", size = 224615, upload-time = "2026-10-03T14:54:44.41Z" }, + { url = "https://files.pythonhosted.org/packages/87/80/c6f2228ad89774429d270179375ebddb657119215f52d1df7c680d65cad7/websockets-17.2-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:0c8600aec354cc259f1691b0b42816f04a9886a953f82cb227246df76057f97a", size = 225398, upload-time = "2026-10-03T14:54:46.063Z" }, + { url = "https://files.pythonhosted.org/packages/f7/4a/3d8da19732ad468d4be7f1e3ac298078b60bdda55edde6589bef84a5eb7e/websockets-17.2-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:307fc22ea496be8542d67b82ae8c867a978dfd19ac35573d4f15943fd9277dfe", size = 226571, upload-time = "2026-10-03T14:54:47.672Z" }, + { url = "https://files.pythonhosted.org/packages/58/22/1231657122d9cc24791bb90af13cc2f4e84cf0d3a454cb37e3abfdcb2fd9/websockets-17.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:9c88697fa943bd4ef67cc919a17d81de6581846f52bfa8c6f64a916098986556", size = 224125, upload-time = "2026-10-03T14:54:49.537Z" }, + { url = "https://files.pythonhosted.org/packages/1a/04/350ca2445da758bc42cdb4218b44d4ce0d5a9c1d5e4cc4a58d64348ad9da/websockets-17.2-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:f7eac84d4969da82166d5e90d9c38d2f416fe24f9708a7013569b193745b9a31", size = 225081, upload-time = "2026-10-03T14:54:51.075Z" }, + { url = "https://files.pythonhosted.org/packages/da/c4/dec952b0df3a5d918ed2a545abb0c25ae519c3bc2d9aba3b7c46abae8f05/websockets-17.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:313f6703023d53baabab6d6c5c37cf637b2c4fee255acf2ed5e92ad69e28f1b7", size = 225376, upload-time = "2026-10-03T14:54:52.675Z" }, + { url = "https://files.pythonhosted.org/packages/f2/b4/198a260afbcc086ff4979774e51834ed7fb5b95f9ef305e0c4924630b857/websockets-17.2-cp314-cp314-win32.whl", hash = "sha256:08d90cf344bdb971ba3a826b78d4da9bfd56cc6a97a604d9b88cbd40bfa6c735", size = 217760, upload-time = "2026-10-03T14:54:54.247Z" }, + { url = "https://files.pythonhosted.org/packages/e5/9e/0523f8bc2f7aaddf39562d4fa01b4d38fa61b23d980917a16d2dd19c8dac/websockets-17.2-cp314-cp314-win_amd64.whl", hash = "sha256:dac93bf7a9beb215be3282b8441173cd50806c41c007b8be9bb24e03c60ad563", size = 218104, upload-time = "2026-10-03T14:54:55.845Z" }, + { url = "https://files.pythonhosted.org/packages/55/17/7b8bb4cb64a199e7082f1f9be784d657842fefc327ac777d6c1493504804/websockets-17.2-cp314-cp314-win_arm64.whl", hash = "sha256:2ab742249f953d148a9ba696c8b9944361e8cb92e8bc61ba2dd53a178403afd3", size = 217989, upload-time = "2026-10-03T14:54:57.376Z" }, + { url = "https://files.pythonhosted.org/packages/ee/76/f54ed054b6e860f1e0bbc7019542a048352d41231fdff6d904b379f881c7/websockets-17.2-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:a69ce25be5f1330ee1c74eb6fabbbceaa96b384beedd2627cecded7546490c40", size = 218125, upload-time = "2026-10-03T14:54:58.943Z" }, + { url = "https://files.pythonhosted.org/packages/e6/4c/0f3375cea66a125ae01d21fb9c537aae955ef499bfe7e2b2376a34362f2a/websockets-17.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:8e24b878cf54843a63985d90480f163ca7f692689fbcbe9cdbd8165521083a8b", size = 215658, upload-time = "2026-10-03T14:55:00.674Z" }, + { url = "https://files.pythonhosted.org/packages/0c/05/7c871a67bfb4b61adc1fe13583db97803f87dfeca644fe6ef51df7bb276d/websockets-17.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:f33c7908a6885dcae9f462a4a8347b637053b4ff2b96beb4c23fba1cf7818e5f", size = 215858, upload-time = "2026-10-03T14:55:02.379Z" }, + { url = "https://files.pythonhosted.org/packages/41/8e/59df4d9cd357e902d1c74b13c3c0c3841c8df6e4b1b3d131bf26a23fdcb1/websockets-17.2-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:c796a1bb3e4015249639849f30e8e680df8a431b45d417ba8acf843d2451d95f", size = 225443, upload-time = "2026-10-03T14:55:03.966Z" }, + { url = "https://files.pythonhosted.org/packages/5c/64/5e486a3a44e041203c62eccf1fc89c7f8824e21104a7b82b182e5b21c228/websockets-17.2-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:983bcdc898662f6ba9d6a025c30d29946ff0986d9ad60d400af0da3671f7cbf3", size = 225726, upload-time = "2026-10-03T14:55:05.797Z" }, + { url = "https://files.pythonhosted.org/packages/f0/98/b6eb53121c91fbe8b6897aba06861ce60f9ab58faffc6bca5750cbc21681/websockets-17.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:35e0f088ddfd9d9bc5019e27ff3767411779e92b59db5bb1507f2731a5b61158", size = 226895, upload-time = "2026-10-03T14:55:07.626Z" }, + { url = "https://files.pythonhosted.org/packages/8a/18/8c091321b99c91eb3eaec9acbd940e69308b4e465b5605c430af0cf7d3a5/websockets-17.2-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:19e2511412ad3393191de652513bc7a0ca3c93af143b32d96d46e59fbbddf1d4", size = 229040, upload-time = "2026-10-03T14:55:09.321Z" }, + { url = "https://files.pythonhosted.org/packages/1a/96/3a92f944305b7de42fcb7530b9fa69607b4b4ce993c36a9f2330dbc318ba/websockets-17.2-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cb5e2bf969ac99a6ae3c71208a5eb05cfde973192540ffa6e1068b57fb78c4f8", size = 227469, upload-time = "2026-10-03T14:55:10.935Z" }, + { url = "https://files.pythonhosted.org/packages/ea/a9/624f6d75ba326c22d03698b34c0ada984f1d76196322a62f6c22903b831d/websockets-17.2-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:691780fca2be3dec512cb603cb91060271968cb4af86b51d07c57445c5754a37", size = 226202, upload-time = "2026-10-03T14:55:12.536Z" }, + { url = "https://files.pythonhosted.org/packages/47/af/1e6e8c625aeb268830af2c4227fe05e8db59f4f4debe1dadfd0ada214895/websockets-17.2-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:2d39c19b1ba6a6791050383fd69efdd3b63533e2254693d0263879cd5f5921ba", size = 223743, upload-time = "2026-10-03T14:55:14.164Z" }, + { url = "https://files.pythonhosted.org/packages/dd/81/33c5280f4f6f81637c93ae065c6a594dfe35935622af135a5f7c3768bf22/websockets-17.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e48ac2b302986c6f55cf61e8e36b4dd97d0132c5078a713a697a940934ba422e", size = 226492, upload-time = "2026-10-03T14:55:15.796Z" }, + { url = "https://files.pythonhosted.org/packages/1d/f3/7aa9fc36e67caccbcfee2c48f4ada41e9da512d41523c024d039f0f22ba3/websockets-17.2-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:e136197f1262620ef2e507afc3ea759c1ae7d221886da20eec5f4c9f2618c2aa", size = 224940, upload-time = "2026-10-03T14:55:17.661Z" }, + { url = "https://files.pythonhosted.org/packages/3f/8c/457aff7081a63d1261608bb4d7b0b0f9dfe780697a2a334671745742850b/websockets-17.2-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:3eb44019a2b0b3b91bac95998f1e4e5589730421170e060fe654a2b7be727dc7", size = 225835, upload-time = "2026-10-03T14:55:19.607Z" }, + { url = "https://files.pythonhosted.org/packages/3e/c3/7a13a3b3050db2c36772ded49f8d48f99eb080948e9f6f762e7529925ab5/websockets-17.2-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:e5855e574804398859c5fbaf4fc7882b96278b7f6572a3d889627e6eb6cfca59", size = 226848, upload-time = "2026-10-03T14:55:21.274Z" }, + { url = "https://files.pythonhosted.org/packages/c4/3e/d5b2c1e473b1031a4a0ec0e10de69df5b981ab4a10aa482bb45c18dd43f5/websockets-17.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:5dc29815520c329f5662f6eb3ebadecf0d4f8c82dfa416d4d6efbf8f39245559", size = 224541, upload-time = "2026-10-03T14:55:22.874Z" }, + { url = "https://files.pythonhosted.org/packages/79/5d/bb81976cc1aa546afb51395ce42913521e9dea062bb34a61308cfff30726/websockets-17.2-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:d1a4f9462da6496b6cb79bbb09c60d17f7e63e8a1df136797b3afabec9560e4d", size = 225315, upload-time = "2026-10-03T14:55:24.443Z" }, + { url = "https://files.pythonhosted.org/packages/f4/6b/314962d5440c61b4c107914599c13ceeecc6bdb6e2e73a5f7e566a7d1f26/websockets-17.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:9496bff5541086478264678bac73c0a75b2fde94fdf6568893bca1f7c6d50d18", size = 225747, upload-time = "2026-10-03T14:55:26.033Z" }, + { url = "https://files.pythonhosted.org/packages/98/fc/9eb64b34a3a4458eb08f3f24bde01508f72a00790330723c158ebb965048/websockets-17.2-cp314-cp314t-win32.whl", hash = "sha256:e1e3bc8090a7eae79fdf634b63bdbfa3c93999991023c37c6fd3b469fc8ff5dc", size = 217891, upload-time = "2026-10-03T14:55:27.681Z" }, + { url = "https://files.pythonhosted.org/packages/ba/ed/3a4e2a09b0822d6e525cbc6e44a4885669bad5b22ab9c64fa2444bc15325/websockets-17.2-cp314-cp314t-win_amd64.whl", hash = "sha256:65a89a5bde227bfe908016f35b5bd347970cd1e5b0360f389502eba1c7fde6e0", size = 218229, upload-time = "2026-10-03T14:55:29.314Z" }, + { url = "https://files.pythonhosted.org/packages/b5/66/cffb75ee746dd060984c3c3e2eac7f875a866225a30dfa53e2cd18232565/websockets-17.2-cp314-cp314t-win_arm64.whl", hash = "sha256:1c27339934109dfaca83f18ab2c23db06714e9d5deca2c8e37e8f492ab90d20b", size = 218146, upload-time = "2026-10-03T14:55:31.001Z" }, + { url = "https://files.pythonhosted.org/packages/8a/58/835cd51934d6780fa586f275b5d9901eead6d81569b4343b3767cdbaae4c/websockets-17.2-py3-none-any.whl", hash = "sha256:6aa59f0ef92e796b2db6f5f26550c4713c0e4036899fadf02f55e2ed4db0b7ae", size = 211883, upload-time = "2026-10-03T14:56:51.898Z" }, +] diff --git a/web/e2e/reader.spec.ts b/web/e2e/reader.spec.ts new file mode 100644 index 0000000..fc1b6fb --- /dev/null +++ b/web/e2e/reader.spec.ts @@ -0,0 +1,142 @@ +import { expect, test } from '@playwright/test'; + +const citation = { + source_revision_id: 'dome:pilot-report:r1', + extraction_id: 'a'.repeat(64), + assertion_path: 'claims/0', + assertion_digest: 'b'.repeat(64), + quote: 'The installation hosted 400 visitors during the season.', + locator: { schema_version: 1, kind: 'text_span', block_id: 'block-1', start: 0, end: 54 }, + normalization_profile: 'unicode-nfc-lf-v1', + schema_version: 1, +}; + +const node = { + node_id: 'dome:season-visitors', + project_id: 'dome-performance', + title: 'Visitor count during the season', + summary: 'The installation hosted 400 visitors.', + node_type: 'finding', + lifecycle: 'active', + as_of: '2026-10-13T00:00:00Z', + tags: ['installation'], + audience: ['developer'], + use_for: ['planning'], + domain: ['interactive systems'], + claims: [{ + text: 'The installation hosted 400 visitors during the season.', + evidence_type: 'reported', + assessment: { + status: 'supported', + method: 'paired source claim check', + rationale: 'The cited sentence states the count.', + assessor_id: 'agent-pilot', + }, + }], + assumptions: [], + limitations: [], +}; + +const envelope = (data: unknown[]) => ({ + api_version: 'v1', + request_id: 'browser-test-request', + resolved_scope: { + projects: ['dome-performance'], + include_shared: false, + all_projects: false, + schema_version: 1, + }, + retrieval_mode: 'keyword', + budget_profile: { tokenizer: 'none', encoding: 'raw-record-v1', schema_version: 1 }, + tokens_used: 150, + data, + citations: [citation], + warnings: [], + omissions: [], + canonical_revision: '1'.repeat(40), + index_revision: '1'.repeat(40), + next_cursor: null, + schema_version: 1, +}); + +test('restores a shared search URL and reads its exact citation', async ({ page }) => { + const searchBodies: Array> = []; + await page.route('**/v1/**', async (route) => { + const request = route.request(); + const url = new URL(request.url()); + if (url.pathname === '/v1/session' && request.method() === 'GET') { + await route.fulfill({ status: 401, json: { detail: 'No browser session' } }); + } else if (url.pathname === '/v1/session') { + await route.fulfill({ json: { csrf_token: 'browser-test-csrf' } }); + } else if (url.pathname === '/v1/projects') { + await route.fulfill({ json: ['dome-performance'] }); + } else if (url.pathname === '/v1/search') { + searchBodies.push(request.postDataJSON() as Record); + await route.fulfill({ json: envelope([{ node, retrieval_score: -1.2 }]) }); + } else if (url.pathname.endsWith('/history')) { + await route.fulfill({ json: envelope([{ revision: '1'.repeat(40), node }]) }); + } else if (url.pathname === '/v1/neighbors') { + await route.fulfill({ json: envelope([]) }); + } else if (url.pathname === '/v1/projects/dome-performance/nodes/dome%3Aseason-visitors') { + await route.fulfill({ json: envelope([node]) }); + } else { + await route.fulfill({ status: 404, json: { detail: 'Unexpected test API request.' } }); + } + }); + + await page.goto('/?project=dome-performance&q=visitors&tag=installation&audience=developer&confidence=medium&as_of_from=2024-01-01&as_of_to=2024-12-31'); + await page.getByRole('button', { name: 'Use an existing credential' }).click(); + await page.getByLabel('GEWU viewer credential').fill('synthetic-viewer-token'); + await page.getByRole('button', { name: 'Connect' }).click(); + + await expect(page.getByRole('button', { name: /Visitor count during the season/ })).toBeVisible(); + await expect.poll(() => searchBodies).toHaveLength(1); + expect(searchBodies[0]).toMatchObject({ + query: 'visitors', + scope: { projects: ['dome-performance'] }, + tags: ['installation'], + audience: ['developer'], + confidences: ['medium'], + as_of_from: '2024-01-01', + as_of_to: '2024-12-31', + }); + + const result = page.getByRole('button', { name: /Visitor count during the season/ }); + await result.focus(); + await page.keyboard.press('Enter'); + await expect(page.getByRole('heading', { name: 'Claim assessments' })).toBeVisible(); + await expect(page.getByRole('region', { name: 'Evidence' }).getByText(citation.quote)).toBeVisible(); + + await page.setViewportSize({ width: 320, height: 800 }); + const documentWidth = await page.evaluate(() => document.documentElement.scrollWidth); + expect(documentWidth).toBe(320); +}); + +test('query editing remains passive until the reader submits Search', async ({ page }) => { + let searchCount = 0; + await page.route('**/v1/**', async (route) => { + const pathname = new URL(route.request().url()).pathname; + if (pathname === '/v1/session' && route.request().method() === 'GET') { + await route.fulfill({ status: 401, json: { detail: 'No browser session' } }); + } else if (pathname === '/v1/session') { + await route.fulfill({ json: { csrf_token: 'browser-test-csrf' } }); + } else if (pathname === '/v1/projects') { + await route.fulfill({ json: ['dome-performance'] }); + } else if (pathname === '/v1/search') { + searchCount += 1; + await route.fulfill({ json: envelope([{ node, retrieval_score: -1.2 }]) }); + } else { + await route.fulfill({ json: envelope([]) }); + } + }); + + await page.goto('/?project=dome-performance'); + await page.getByRole('button', { name: 'Use an existing credential' }).click(); + await page.getByLabel('GEWU viewer credential').fill('synthetic-viewer-token'); + await page.getByRole('button', { name: 'Connect' }).click(); + await page.getByRole('textbox', { name: /Search claims/ }).fill('visitors'); + await expect.poll(() => searchCount).toBe(0); + await page.getByRole('textbox', { name: /Search claims/ }).press('Enter'); + await expect.poll(() => searchCount).toBe(1); + await expect(page.getByRole('button', { name: /Visitor count during the season/ })).toBeVisible(); +}); diff --git a/web/eslint.config.js b/web/eslint.config.js new file mode 100644 index 0000000..5a07801 --- /dev/null +++ b/web/eslint.config.js @@ -0,0 +1,20 @@ +import js from '@eslint/js'; +import globals from 'globals'; +import reactHooks from 'eslint-plugin-react-hooks'; +import reactRefresh from 'eslint-plugin-react-refresh'; +import tseslint from 'typescript-eslint'; + +export default tseslint.config( + { ignores: ['dist', 'node_modules'] }, + js.configs.recommended, + ...tseslint.configs.recommended, + { + files: ['**/*.{ts,tsx}'], + languageOptions: { globals: { ...globals.browser, ...globals.node } }, + plugins: { 'react-hooks': reactHooks, 'react-refresh': reactRefresh }, + rules: { + ...reactHooks.configs.recommended.rules, + 'react-refresh/only-export-components': ['warn', { allowConstantExport: true }], + }, + }, +); diff --git a/web/index.html b/web/index.html new file mode 100644 index 0000000..b975dd4 --- /dev/null +++ b/web/index.html @@ -0,0 +1,13 @@ + + + + + + + GEWU — Research Library + + +
+ + + diff --git a/web/package-lock.json b/web/package-lock.json new file mode 100644 index 0000000..92abfd0 --- /dev/null +++ b/web/package-lock.json @@ -0,0 +1,4771 @@ +{ + "name": "gewu-web", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "gewu-web", + "version": "0.1.0", + "dependencies": { + "@tanstack/react-query": "^5.90.0", + "react": "^19.2.0", + "react-dom": "^19.2.0", + "react-router": "^7.9.0" + }, + "devDependencies": { + "@eslint/js": "^9.37.0", + "@playwright/test": "^1.64.0", + "@testing-library/jest-dom": "^6.9.0", + "@testing-library/react": "^16.3.0", + "@testing-library/user-event": "^14.6.1", + "@types/node": "^24.7.0", + "@types/react": "^19.2.0", + "@types/react-dom": "^19.2.0", + "@vitejs/plugin-react": "^5.0.4", + "eslint": "^9.37.0", + "eslint-plugin-react-hooks": "^5.2.0", + "eslint-plugin-react-refresh": "^0.4.23", + "globals": "^16.4.0", + "jsdom": "^27.0.0", + "openapi-typescript": "^7.13.0", + "typescript": "~5.9.3", + "typescript-eslint": "^8.46.0", + "vite": "^7.1.10", + "vitest": "^5.0.4" + } + }, + "node_modules/@acemir/cssom": { + "version": "0.9.32", + "resolved": "https://registry.npmjs.org/@acemir/cssom/-/cssom-0.9.32.tgz", + "integrity": "sha512-OijrAUu7rrJhSIoHNg6LMPS5e344nwUHA+1EFYYjrh64D+GmytUsfBQjci45TjT+rd5Spsp8B5xsZDT+TgyXXQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@adobe/css-tools": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.5.0.tgz", + "integrity": "sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@asamuzakjp/css-color": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-4.1.2.tgz", + "integrity": "sha512-NfBUvBaYgKIuq6E/RBLY1m0IohzNHAYyaJGuTK79Z23uNwmz2jl1mPsC5ZxCCxylinKhT1Amn5oNTlx1wN8cQg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@csstools/css-calc": "^3.0.0", + "@csstools/css-color-parser": "^4.0.1", + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0", + "lru-cache": "^11.2.5" + } + }, + "node_modules/@asamuzakjp/css-color/node_modules/lru-cache": { + "version": "11.5.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.3.tgz", + "integrity": "sha512-U4N8FgzmWxc8k1VH8Kr6lQg18U7Fjvby6wXHVRX/ZZ7IwWbRMgrRbP0Wrb5q5NVinryp4SQampHKdvtecItxUg==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/@asamuzakjp/dom-selector": { + "version": "6.8.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-6.8.1.tgz", + "integrity": "sha512-MvRz1nCqW0fsy8Qz4dnLIvhOlMzqDVBabZx6lH+YywFDdjXhMY37SmpV1XFX3JzG5GWHn63j6HX6QPr3lZXHvQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/nwsapi": "^2.3.9", + "bidi-js": "^1.0.3", + "css-tree": "^3.1.0", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.2.6" + } + }, + "node_modules/@asamuzakjp/dom-selector/node_modules/lru-cache": { + "version": "11.5.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.3.tgz", + "integrity": "sha512-U4N8FgzmWxc8k1VH8Kr6lQg18U7Fjvby6wXHVRX/ZZ7IwWbRMgrRbP0Wrb5q5NVinryp4SQampHKdvtecItxUg==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/@asamuzakjp/nwsapi": { + "version": "2.3.9", + "resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz", + "integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.8.tgz", + "integrity": "sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.8", + "@babel/types": "^7.29.8", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz", + "integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.10", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.10.tgz", + "integrity": "sha512-EMCx3AGZ3WfAl6ht7AyQoPm2Lh+BGYEMdc4Q6Hf7uSCDOZFuLq44qNwL+iaim8VlyYhRAo4X43+weadK8ZB4Yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.8" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.9", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.9.tgz", + "integrity": "sha512-CjXrNHTnvqBVqHgdBysY3vk2T8tpJHb5/RMeHJBTyVa9xgugCB0CJTx/3oO8RV2QRQP391RWpB7D6hLjm8V9uA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.8" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.29.7.tgz", + "integrity": "sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.29.7.tgz", + "integrity": "sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.29.10", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.10.tgz", + "integrity": "sha512-NpugTzbKgGrNe45Ui32cN8vCFFMxVtP/9rtfwv0nP5FRDAjvYBxJcLVpYA9mr1jxP6cnIT7x9LUzuDTbWYYNRA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/template": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.10", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.10.tgz", + "integrity": "sha512-B3lYAs2nkKskbgQ1nfy8Ma9ao9Q1rntTAQHrzzyDuj+TSO+X3le6PfWwqDvodsb+OumE10eMFfuMCTQSQ5iuIQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.8", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.9", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.8", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz", + "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@csstools/color-helpers": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.1.2.tgz", + "integrity": "sha512-grhRy3OKmniaAEKXMjua5z/EODX0MSqBGjunw8+j/3HQjOnahs2AGhvEOIYVUWcU6ScApbhLhVrQTX8XqrMrow==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@csstools/css-calc": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.4.3.tgz", + "integrity": "sha512-iex20d8CHVkyvg6B7UKV7uHnI2Bqo9g+EFfT9E0y+GvTvhZ/DwONJ+9aKb1dlqm0ZiGsL5RXjp0fCoJYnkeDjA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.2", + "@csstools/css-tokenizer": "^4.0.2" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "4.2.6", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.2.6.tgz", + "integrity": "sha512-iiPQ3iRWwnJkeEn6RIu6SJPr7hYrLz6XZ9s/QZl+2/LI5KQVjpl2fdmDSZKuD4xP6GMmMPgHFFXg6k1Wkz0Trg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^6.1.2", + "@csstools/css-calc": "^3.4.3" + }, + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.2", + "@csstools/css-tokenizer": "^4.0.2" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.2.tgz", + "integrity": "sha512-40cSKyMvK+tq4qz6Awrlye2WGuOKt3FwPgtGg6KTfbHOWNw+Rk1rzbAtZnZ6IBhsY491HLRnDXwoyBAijmmILA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^4.0.2" + } + }, + "node_modules/@csstools/css-syntax-patches-for-csstree": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.15.tgz", + "integrity": "sha512-J0u7HkVl2nzSlhsiTOp4AmwcUQ3D+mGEEKfBy/7To5/y7F2OHwyLrXfrhR0SMgr4p5Lo+eaMVSeai24zUcBIxA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "peerDependencies": { + "css-tree": "^3.2.1" + }, + "peerDependenciesMeta": { + "css-tree": { + "optional": true + } + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.2.tgz", + "integrity": "sha512-OoKoR0f76dCY666JlcbhmVTs2drYj1GUXZTYTcbUgJjh9Nv41aFfZ21bPQTERm5+L5cBDo466NltB2lplS5GBw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz", + "integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz", + "integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz", + "integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz", + "integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz", + "integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz", + "integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz", + "integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz", + "integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz", + "integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz", + "integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz", + "integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz", + "integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz", + "integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz", + "integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz", + "integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz", + "integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz", + "integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz", + "integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz", + "integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz", + "integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz", + "integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz", + "integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz", + "integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz", + "integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz", + "integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz", + "integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.10.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.10.1.tgz", + "integrity": "sha512-cuadcxVFE8sDK6iWJbs8Sn0av2Nrh2QSGQhVlBW9AaAHqHwjWsZHT8LJ4hFGPh7ASBV2deFdM7H/DPjulmh8rg==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.21.2", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.2.tgz", + "integrity": "sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^2.1.7", + "debug": "^4.3.1", + "minimatch": "^3.1.5" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz", + "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/core": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz", + "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "3.3.7", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.7.tgz", + "integrity": "sha512-F42g89Qd5oAWtp0k0nnSrjziAKza7w8SVT4mStc18LZMaRb4J1HQAHLCalEtDCxrTuksx7NU9qsmeLwpOfPqWw==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^6.14.0", + "debug": "^4.3.2", + "espree": "^10.0.1", + "globals": "^14.0.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.3.2", + "minimatch": "^3.1.5", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/eslintrc/node_modules/globals": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", + "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@eslint/js": { + "version": "9.39.5", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.5.tgz", + "integrity": "sha512-QywQuszQh77pIXCsq998c8hbhSTI/azTty1Z6N53dmAudKHhy573j3yvRLsX2BSp8YpLtoCEG8E9DJe+8zUh4A==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + } + }, + "node_modules/@eslint/object-schema": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz", + "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz", + "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0", + "levn": "^0.4.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@exodus/bytes": { + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.16.0.tgz", + "integrity": "sha512-IcpW84uEn3N7ETtNZMlxKhfl6Pec8rUNGOTBtWbK1FKhJxIFAptZyVrvVRVBimAJxJCgc3PxepxkdWWG4DVzfA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "@noble/hashes": "^1.8.0 || ^2.0.0" + }, + "peerDependenciesMeta": { + "@noble/hashes": { + "optional": true + } + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", + "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/types": "^0.15.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.8", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz", + "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.2", + "@humanfs/types": "^0.15.0", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/types": { + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz", + "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", + "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@napi-rs/lzma-linux-x64-gnu": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz", + "integrity": "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^22.20 || ^24.12 || >=25" + } + }, + "node_modules/@playwright/test": { + "version": "1.64.0", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.64.0.tgz", + "integrity": "sha512-oTN9FNYuN6bqQMwiho6jJKLCG2/QOYCixGXuCjRmzOq23eODeukuY2XiAVoVpAECbuezMt1MHQjAiQxK8F+Y+g==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.64.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@redocly/ajv": { + "version": "8.11.2", + "resolved": "https://registry.npmjs.org/@redocly/ajv/-/ajv-8.11.2.tgz", + "integrity": "sha512-io1JpnwtIcvojV7QKDUSIuMN/ikdOUd1ReEnUnMKGfDVridQZ31J0MmIuqwuRjWDZfmvr+Q0MqCcfHM2gTivOg==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2", + "uri-js-replace": "^1.0.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/@redocly/ajv/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "dev": true, + "license": "MIT" + }, + "node_modules/@redocly/config": { + "version": "0.22.0", + "resolved": "https://registry.npmjs.org/@redocly/config/-/config-0.22.0.tgz", + "integrity": "sha512-gAy93Ddo01Z3bHuVdPWfCwzgfaYgMdaZPcfL7JZ7hWJoK9V0lXDbigTWkhiPFAaLWzbOJ+kbUQG1+XwIm0KRGQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@redocly/openapi-core": { + "version": "1.34.21", + "resolved": "https://registry.npmjs.org/@redocly/openapi-core/-/openapi-core-1.34.21.tgz", + "integrity": "sha512-yXMiBFYJbP5JOaxPzx2tJhK+v8h2Wu2uhad1Ro6GzVT8E3F4GcQ7sbLPJ9w4zvGjfxroD0bIh5B0pDtnLV/qUA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@redocly/ajv": "8.11.2", + "@redocly/config": "0.22.0", + "colorette": "1.4.0", + "https-proxy-agent": "7.0.6", + "js-levenshtein": "1.1.6", + "js-yaml": "4.3.2", + "minimatch": "5.1.9", + "pluralize": "8.0.0", + "yaml-ast-parser": "0.0.43" + }, + "engines": { + "node": ">=18.17.0", + "npm": ">=9.5.0" + } + }, + "node_modules/@redocly/openapi-core/node_modules/brace-expansion": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.7.tgz", + "integrity": "sha512-uZbew1NqdmPDTMJ8ah1y+b+9QEJrfkXFk3RcTQw3X0jW/xRUvFKsg1CfQdSYGdTbXZWExtU3J3ccxtnfw1Fi0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/@redocly/openapi-core/node_modules/minimatch": { + "version": "5.1.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.9.tgz", + "integrity": "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-rc.3", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.3.tgz", + "integrity": "sha512-eybk3TjzzzV97Dlj5c+XrBFW57eTNhzod66y9HrBlzJ6NsCrWCp/2kaPS3K9wJmurBC0Tdw4yPjXKZqlznim3Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.64.6", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.64.6.tgz", + "integrity": "sha512-FkO19Fowv/OQeJZPVfdetJaI3/VpbZmBsnmAlR/rQ+b3QwfiGQiOpVegpSRH331kelvHTVw9hXYG3DwjAaRzyQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.64.6", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.64.6.tgz", + "integrity": "sha512-si5/Etm6MphPc2xYHnk455Shls/hoMjTy3mhXge4OPmvCw7TwC4KP4fKokAlKLV4XaQvLvgSHfafPcblLMYYdQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.64.6", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.64.6.tgz", + "integrity": "sha512-PQCl3q7b0BgvVt72i2xrWM6I5KliwwVe4GpWW5QDGyUQawDS9aPZss/aGyFqBmxdRxlE8wDc9CaxiQ5DHRSp/Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.64.6", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.64.6.tgz", + "integrity": "sha512-l9DFl1Itb1YYdSrP1K3U8ih44AOHa/p+Q4WMMDfGvH5qVWp+ajfgS1DlYo4yCRh8wJ2ZJv0Fqccf+ULAmMFjmg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.64.6", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.64.6.tgz", + "integrity": "sha512-bR4GR3205N9+od0+l5Dd9/iZTDpJkIe6d6Ydp2qbcQZJLmKBdYQQRp8gbWpbMt92twKJY66o1vK4NkoKQODIrA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.64.6", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.64.6.tgz", + "integrity": "sha512-XTVNaDzcFl2TeBglvxdGgsCv9JGtAGRAoJENmGBR/XvDH2mA2K0IXc2aMfP0dR8mexSXSKifJ85FixoXX1wfTw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.64.6", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.64.6.tgz", + "integrity": "sha512-qWc6qbc+25ugG9vqPR9P422KJBbVfZ05mjcMggoq8tsfWP+eRfC+/tiDdIlRF5VL2pDVZWN9zAhWsmu7R1+7Dw==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.64.6", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.64.6.tgz", + "integrity": "sha512-V4Nwkp8844X/Xnww3PI1LYS0YPei8XQEVG70R+5MPZGO7AhYoun4hCN7Svs4U3wbQLZbwiz+9xRFhUZZdRl8Ww==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.64.6", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.64.6.tgz", + "integrity": "sha512-xb72A1aPaaIsp+a88VtRae/ovIEXislbUMyp7b9AQQU7ZkP2m/+ik6DW9TQcJyw5R8/RIailF9zExYwkjR++UA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.64.6", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.64.6.tgz", + "integrity": "sha512-pbcs1BVBwC43hZkjhjlF8Rx8m5G0KnhLZs9Azy/gqccP6UsNSzJr0hKGIAVPqzAH1O/NdPxxC9GVGb4msQg/aw==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.64.6", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.64.6.tgz", + "integrity": "sha512-EKOC8os6Vsg487ZLKUqzLF99Jsmnq6tu7O7mAELakNpcA+8KadktFf/JYdRuHe/DzCUXmN38pUKu4XBcKlqGbQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.64.6", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.64.6.tgz", + "integrity": "sha512-v/SQ68GnSlr/HyjB79sNXesGDwkOPu9JjbHfmWaymCiM+nZsQ9TstG1KfA4MuEvTr2d3PoY8PhFxj9bt0e32JA==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.64.6", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.64.6.tgz", + "integrity": "sha512-G3pmliMQ7r15zFFNwBlaT/xTdSLi4lfgI7CUrQueyKYheNVhrd8JrWCE6Ll8xf4AA1RZFqq+mNJYntiElsVOyA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.64.6", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.64.6.tgz", + "integrity": "sha512-XQTB/IPoB/wG6x/wu9uomAt8YH61IAr5iZTubMiad/lu88sotq2ocz9hJd556+Jz2kIkF7ZQZIsUuWE3OWqtcA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.64.6", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.64.6.tgz", + "integrity": "sha512-O5mpokth6+ydMuwiGtY8fIjvOBfH7u6SzmDNmZ3k6GK5C6iEAC2q2ZN/T5L9jYILx0IcCT0KnfMDihQ5yQFSBw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.64.6", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.64.6.tgz", + "integrity": "sha512-aIFxbAD9v3TvIaEDsKmukjcDg1JS4/tFXw8SHyQQgzOwzWCQ4cyDK7Ju1pQKqUXDFkEu/BZCFDCDATsKhQcB/Q==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.64.6", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.64.6.tgz", + "integrity": "sha512-fuNo1UwBc6BxnHDOxxguQL/InhheMN3RJcJMb0b4xP8r5Q3CNCrJbVg0eCIFuJ4e/zAfjR2gYbOfbzO/G7CIyw==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.64.6", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.64.6.tgz", + "integrity": "sha512-05v6wFKDKJPZs9xlytVrrRu6IbRuoSvlH9xNwEOafXsKyjBFZPvAcZz2VIAB/M+lblYeKa9iOYC4RDVdfcAAaw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.64.6", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.64.6.tgz", + "integrity": "sha512-wJXKcdMjvkc/FaavZXNP1yW4i0pfOmhZAIWRSLG2yJ0PB0TWi7dkLErhX0u0vptDcXKKO/m1Y/YTeuyvb2MXwg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.64.6", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.64.6.tgz", + "integrity": "sha512-cwg7/h0ThvJwy8CBkmACdiHAxRWpbsCmIsjOt+Zzx0hTnIcSUmKYEV03bGg+hD827OnwUsbKh1ZZDW754xWyBA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.64.6", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.64.6.tgz", + "integrity": "sha512-T0RkYIJbdYXJEQFw+tMBO09KDFdgmNxkHsof3wWAABrvnx15Yh8RytmrgYy9+q9cBeBNZF66MMQZC+5nzEz6vg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.64.6", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.64.6.tgz", + "integrity": "sha512-KlfpmF1AAIdAWxt8QQNClbpLFlAUfYOzqIuJ7IXEStMHpFRuH78RG6FjqPb3Rqhh0K89F5npvG+/i3Fjl6IveQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.64.6", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.64.6.tgz", + "integrity": "sha512-MrTLjd7Jlt9/ZavJZYG61KQv9Iy9bzbY+yXy73Xvwoa2uP66wvDShkyBr5Chz/yrE6lbD/zHMuTmKQVNQqBApg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.64.6", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.64.6.tgz", + "integrity": "sha512-8fWDH1CkPI8GdBKAxzHDza7UmgficsKo2+MVbvUgOGhB0629w5sHMg3Qu5y7TZsrYKHHWpz8fla4KvoT7WsO3Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.64.6", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.64.6.tgz", + "integrity": "sha512-9/c9PWdr5GCIHkkxiHM1HjNBQMVdftP4O8QTcoUAaRza2FSqJ4axbf8QAnBuE6BIGQDl71SaRPeDdDYid5ecTw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@tanstack/query-core": { + "version": "5.104.2", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.104.2.tgz", + "integrity": "sha512-/qbsxJK5c3Ve1aFIULWYNcT2jeRUAiD4HME6Eu2ZdpofHoVgAjpkQhLg5xHuOoBkTPQUJLfP7zOxy0qsBcqROA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/react-query": { + "version": "5.104.2", + "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.104.2.tgz", + "integrity": "sha512-xKmzWGhadbE5wGS/ZmXShRITnI9oItox5GO14KvFZco3+vhTAyXS1erYzJWdiY280zapAGJXFUL9nHgwP6SV/g==", + "license": "MIT", + "dependencies": { + "@tanstack/query-core": "5.104.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^18 || ^19" + } + }, + "node_modules/@testing-library/dom": { + "version": "10.4.2", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.2.tgz", + "integrity": "sha512-yzr2S9HyAIdhz2/6qHgbs665Q7PKVcDF05vsOlHPxG1mo36gKVesdYVeDLnXgfjJ03CrKRk08knc6+E/9m8v2Q==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/code-frame": "^7.10.4", + "@babel/runtime": "^7.12.5", + "@types/aria-query": "^5.0.1", + "aria-query": "5.3.0", + "dom-accessibility-api": "^0.5.9", + "lz-string": "^1.5.0", + "picocolors": "1.1.1", + "pretty-format": "^27.0.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@testing-library/jest-dom": { + "version": "6.9.1", + "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.9.1.tgz", + "integrity": "sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@adobe/css-tools": "^4.4.0", + "aria-query": "^5.0.0", + "css.escape": "^1.5.1", + "dom-accessibility-api": "^0.6.3", + "picocolors": "^1.1.1", + "redent": "^3.0.0" + }, + "engines": { + "node": ">=14", + "npm": ">=6", + "yarn": ">=1" + } + }, + "node_modules/@testing-library/jest-dom/node_modules/dom-accessibility-api": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz", + "integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@testing-library/react": { + "version": "16.3.3", + "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.3.tgz", + "integrity": "sha512-Uo193NgQbPMz6lrrhtRQQFcMC6Re/ELLFbbuVL30WDlZxlpZf9/lMHTAVxPRLw1q1iu9OJmR1c2BLiENRstdBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.12.5" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@testing-library/dom": "^10.0.0", + "@types/react": "^18.0.0 || ^19.0.0", + "@types/react-dom": "^18.0.0 || ^19.0.0", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@testing-library/user-event": { + "version": "14.6.8", + "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.6.8.tgz", + "integrity": "sha512-vgSSqnANMnkcYR/vUNDbwS7aM3OErRLNUG8vCVVlj4oc6cKffUrccX7UAS5AmJQQNZTSibi9z5GvvRZ0aYgUCQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12", + "npm": ">=6" + }, + "peerDependencies": { + "@testing-library/dom": ">=7.21.4" + } + }, + "node_modules/@types/aria-query": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", + "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "24.19.2", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.19.2.tgz", + "integrity": "sha512-93d49wCWJkCyN87MxLpNN6KXPCwFw7bVUg79mGUV1OS40JGWwVCr8n61DXCSqz7CV6/VfPkb7xec7uP2M+QHWQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": ">=7.24.0 <7.24.7" + } + }, + "node_modules/@types/react": { + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.3.0.tgz", + "integrity": "sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg==", + "dev": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.3.0.tgz", + "integrity": "sha512-ZI7bU42mZXXKHn/qNLEw2IrbiINU7X5+vfgdixBHkCNpYWXjKgfQ/P+uyGb5CjOLB9UcnTeg3rylQtV2hym44Q==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.3.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.71.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.71.2.tgz", + "integrity": "sha512-K72/pvT7qpm8q3k8G5u93NCSN1RcLxX5h7Gw0bQpbMXviBHeY6Nx1WEwmk9wP+eKTSQ8ecxvcgt/ZX/rB938xQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.71.2", + "@typescript-eslint/type-utils": "8.71.2", + "@typescript-eslint/utils": "8.71.2", + "@typescript-eslint/visitor-keys": "8.71.2", + "ignore": "^7.0.5", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.71.2", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { + "version": "7.0.12", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.12.tgz", + "integrity": "sha512-/8UvqAPU9DGTI9k4mxtf49U37Isfwr8Uts96+SBHIkFxPJnHS0Ew4f00sM4Scd8V8EjM0jUNtVDdv1kPdt35lg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.71.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.71.2.tgz", + "integrity": "sha512-aTtmZjp/x3gsGHkDs04/1I8kNogBJkpQaeGaN0PfiG43NzDEmW2n+OP5im9wfsvyX0yP1UZWBrAGtDBfH9jEVw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "8.71.2", + "@typescript-eslint/types": "8.71.2", + "@typescript-eslint/typescript-estree": "8.71.2", + "@typescript-eslint/visitor-keys": "8.71.2", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.71.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.71.2.tgz", + "integrity": "sha512-Pd68GsLVh5ZXlcEccFPdhSOvtHZ/2WU0XsA8zmXOS3F158lVlcTLhfQ6qvtL1RzbMie0Kp8xtbVwAg4nejKPHw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.71.2", + "@typescript-eslint/types": "^8.71.2", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.71.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.71.2.tgz", + "integrity": "sha512-YF4wgg5iUX5Obywoac27B56FhozMNFhdJSbh11H5GBAwmBtfyRhmgTQqW9tRNNmHDvsZ2CX6UGzoz+YfYn2Leg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.71.2", + "@typescript-eslint/visitor-keys": "8.71.2" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.71.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.71.2.tgz", + "integrity": "sha512-jz49TAWokm/7yWD9/o837nYax9DyALxIPOImdImvVzo6canBCZYzin8zJyqNR5k1j9K1DE0bVvr0XYB+99uAdg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.71.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.71.2.tgz", + "integrity": "sha512-cC8UaalCViJkb3rH/xLIvidEwoFRtMZMUqDjjJMGEKaTFwYRHYBcoczlEGHuvkHEO8svRDrgZ0yQgOArFmwang==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.71.2", + "@typescript-eslint/typescript-estree": "8.71.2", + "@typescript-eslint/utils": "8.71.2", + "debug": "^4.4.3", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.71.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.71.2.tgz", + "integrity": "sha512-y5Ukl3oVp7e/VGxqIoEXoeb/Roj71PAJ4WG/N/RGj+eeDvKRLM15kGr9MijLbN0i03S52zxu31Z98E5gWChJKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.71.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.71.2.tgz", + "integrity": "sha512-DgNOEsl4pdYzwSL+N/Cl33tX1Hlw3FvId73LnKXfq7jnrF2viOEn7X+P7RBkkSWCVm92gnWd28GIC5vJuLREeA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.71.2", + "@typescript-eslint/tsconfig-utils": "8.71.2", + "@typescript-eslint/types": "8.71.2", + "@typescript-eslint/visitor-keys": "8.71.2", + "debug": "^4.4.3", + "minimatch": "^10.2.2", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { + "version": "5.0.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz", + "integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { + "version": "10.2.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", + "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.8" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.71.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.71.2.tgz", + "integrity": "sha512-RyKa1G1M9rKHDhrk+HJtiF1MvLD5Ux2JW3TSkzw+02pRALdpJb+QzG1Y5em+LvCgzGwgg8H7mBRFKljNz7ImPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.71.2", + "@typescript-eslint/types": "8.71.2", + "@typescript-eslint/typescript-estree": "8.71.2" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.71.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.71.2.tgz", + "integrity": "sha512-zozlN0UA32bSG2zkKAgQyRYSbEISVoyU6vvkKYo2aSny74D8Jh4e+POkJ6Lk0dlezZi5x7TtD+88Xb06IB8nhg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.71.2", + "eslint-visitor-keys": "^5.0.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.2.0.tgz", + "integrity": "sha512-YmKkfhOAi3wsB1PhJq5Scj3GXMn3WvtQ/JC0xoopuHoXSdmtdStOpFrYaT1kie2YgFBcIe64ROzMYRjCrYOdYw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.29.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-rc.3", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.18.0" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/@vitest/mocker": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-5.0.4.tgz", + "integrity": "sha512-tqAlrYWce4iUxhr3ncNohejtCTOcHeE8XWrSNZY3OhPoDn01yeGv+gnaBy64ATp7o6pb03eIbBAjzmibpc9vIQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "0.3.31", + "@vitest/spy": "5.0.4", + "estree-walker": "^3.0.3", + "magic-string": "^1.4.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9 || ^3.0.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/spy": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-5.0.4.tgz", + "integrity": "sha512-MZKk/gSkm2q+ICobMxCpH1mA+cCAWm8WV7UhxlqGjq/sJ3AGc1BqFOs5CRVXxH78wQtme9q/NAAgBquj4AcyMw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/acorn": { + "version": "8.19.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.19.0.tgz", + "integrity": "sha512-oJlA3XiRm7Cyk6qFD2Jn8ak9B7jSy0qy00ADO3+8dpT0LSjFihQYv4C02LFCSYJV3Q37xYwwRy5m+IpIiUzqWw==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/ajv": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ansi-colors": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-4.1.3.tgz", + "integrity": "sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/aria-query": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", + "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "dequal": "^2.0.3" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/baseline-browser-mapping": { + "version": "2.11.28", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.28.tgz", + "integrity": "sha512-br0LOmjJqr6Rt4AEkadbcNf67Zpo7EPwKsWbjnRrJIa1QYFHQBqM4C7F2WTbV8ahTEcg1Eo+Y0aWjjORInYFyA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/bidi-js": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.1.0.tgz", + "integrity": "sha512-fX1Onk0tdVPC7obPWB5EbJ1z7NVhLq4m2xZLq2YXBkxzMXIGRpNMU88n0EPgWseKl12J7zXs7qrDxPK4sRs2fg==", + "dev": true, + "license": "MIT", + "dependencies": { + "require-from-string": "^2.0.2" + } + }, + "node_modules/brace-expansion": { + "version": "1.1.21", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.21.tgz", + "integrity": "sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/browserslist": { + "version": "4.29.3", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.29.3.tgz", + "integrity": "sha512-1R4kiYKXGViqEN0CnoDrXc1StD9niAwu+j2dukWzrD4bJgsD4lDmEp0CRbc6E/vYJIfTHwPmwyaKtVSudICdPA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.11.26", + "caniuse-lite": "^1.0.30001813", + "electron-to-chromium": "^1.5.439", + "node-releases": "^2.0.57", + "update-browserslist-db": "^1.3.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001815", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001815.tgz", + "integrity": "sha512-uDybUEyfgbyJJ93J1vikTylBX31AkdVYeVPeffMQpf8XN7hSfr0oZOn4VFOIMNvtU5+rHvC5Udg2TgtJKHm23Q==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chai": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.3.0.tgz", + "integrity": "sha512-XWAtwJ6OHO+tj0EKCs0Y2UamnyOxseZWltU4x2U2wh8g4AigdjwvtUjvLP2tqkA/avxHEtzxNaqGq/YGNwckKg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/change-case": { + "version": "5.4.4", + "resolved": "https://registry.npmjs.org/change-case/-/change-case-5.4.4.tgz", + "integrity": "sha512-HRQyTk2/YPEkt9TnUPbOpr64Uw3KOicFWPVBb+xiHvd6eBx/qPr9xqfBFDT8P2vWsvvz4jbEkfDe71W3VyNu2w==", + "dev": true, + "license": "MIT" + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/colorette": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/colorette/-/colorette-1.4.0.tgz", + "integrity": "sha512-Y2oEozpomLn7Q3HFP7dpww7AtMJplbM9lGZP6RDfHqmbeRjiwRg4n6VM6j4KLmRke85uWEI7JqF17f3pqdRA0g==", + "dev": true, + "license": "MIT" + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/css-tree": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", + "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "mdn-data": "2.27.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, + "node_modules/css.escape": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz", + "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cssstyle": { + "version": "5.3.7", + "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-5.3.7.tgz", + "integrity": "sha512-7D2EPVltRrsTkhpQmksIu+LxeWAIEk6wRDMJ1qljlv+CKHJM+cJLlfhWIzNA44eAsHXSNe3+vO6DW1yCYx8SuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^4.1.1", + "@csstools/css-syntax-patches-for-csstree": "^1.0.21", + "css-tree": "^3.1.0", + "lru-cache": "^11.2.4" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/cssstyle/node_modules/lru-cache": { + "version": "11.5.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.3.tgz", + "integrity": "sha512-U4N8FgzmWxc8k1VH8Kr6lQg18U7Fjvby6wXHVRX/ZZ7IwWbRMgrRbP0Wrb5q5NVinryp4SQampHKdvtecItxUg==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/data-urls": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-6.0.1.tgz", + "integrity": "sha512-euIQENZg6x8mj3fO6o9+fOW8MimUI4PpD/fZBhJfeioZVy9TUpM4UY7KjQNVZFlqwJ0UdzRDzkycB997HEq1BQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^15.1.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/data-urls/node_modules/whatwg-mimetype": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", + "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/dom-accessibility-api": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", + "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/electron-to-chromium": { + "version": "1.5.453", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.453.tgz", + "integrity": "sha512-9r/nz9akllr3SZsHUhfK10ktUH6j0Dbo3cKdh91khw0FvR6HhXGnATmOq5X8ena2aq4GXLQ1koh3nvKaQJaNmQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/entities": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-8.1.0.tgz", + "integrity": "sha512-kxL7msIffSuh9aaFAMD7rxAIuTRMAHMeBtgHW2yUdWw732ZNh4MehkF2gdjvtdmikkaIP9bFDDJOPlsvm7avrA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/es-module-lexer": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.2.tgz", + "integrity": "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==", + "dev": true, + "license": "MIT" + }, + "node_modules/esbuild": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz", + "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "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" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "9.39.5", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.5.tgz", + "integrity": "sha512-DgZS62aPLXKlnxILS/AYCoRvHaZeXceIzlXPkkGGzJWSow1aEk0lbTlxUSlyjC8jcaKxAdOnTDz+o1JFSBsyjw==", + "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.1", + "@eslint/config-array": "^0.21.2", + "@eslint/config-helpers": "^0.4.2", + "@eslint/core": "^0.17.0", + "@eslint/eslintrc": "^3.3.6", + "@eslint/js": "9.39.5", + "@eslint/plugin-kit": "^0.4.1", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.14.0", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^8.4.0", + "eslint-visitor-keys": "^4.2.1", + "espree": "^10.4.0", + "esquery": "^1.5.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.5", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-plugin-react-hooks": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-5.2.0.tgz", + "integrity": "sha512-+f15FfK64YQwZdJNELETdn5ibXEUQmW1DZL6KXhNnc2heoy/sg9VJJeT7n8TlMWouzWqSWavFkIhHyIbIAEapg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0" + } + }, + "node_modules/eslint-plugin-react-refresh": { + "version": "0.4.26", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-refresh/-/eslint-plugin-react-refresh-0.4.26.tgz", + "integrity": "sha512-1RETEylht2O6FM/MvgnyvT+8K21wLqDNg4qD51Zj3guhjt433XbnnkVttHMyaVyAFD03QSV4LPS5iE3VQmO7XQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "eslint": ">=8.40" + } + }, + "node_modules/eslint-scope": { + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", + "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", + "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.15.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.4.4", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.4.tgz", + "integrity": "sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q==", + "dev": true, + "license": "ISC" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/globals": { + "version": "16.5.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-16.5.0.tgz", + "integrity": "sha512-c/c15i26VrJ4IRt5Z89DnIzCGDn9EcebibhAOjw5ibqEHsE1wLUgkPn9RDmNcUKyU87GeaL633nyJ+pplFR2ZQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/html-encoding-sniffer": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", + "integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.6.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/indent-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", + "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/index-to-position": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/index-to-position/-/index-to-position-1.2.0.tgz", + "integrity": "sha512-Yg7+ztRkqslMAS2iFaU+Oa4KTSidr63OsFGlOrJoW981kIYO3CGCS3wA95P1mUi/IVSJkn0D479KTJpVpvFNuw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/js-levenshtein": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/js-levenshtein/-/js-levenshtein-1.1.6.tgz", + "integrity": "sha512-X2BB11YZtrRqY4EnQcLX5Rh373zbK4alC1FW7D7MBhL2gtcC17cTnr6DmfHZeS0s2rTHjUTMMHfG7gO8SSdw+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/js-yaml": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.2.tgz", + "integrity": "sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/jsdom": { + "version": "27.4.0", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-27.4.0.tgz", + "integrity": "sha512-mjzqwWRD9Y1J1KUi7W97Gja1bwOOM5Ug0EZ6UDK3xS7j7mndrkwozHtSblfomlzyB4NepioNt+B2sOSzczVgtQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@acemir/cssom": "^0.9.28", + "@asamuzakjp/dom-selector": "^6.7.6", + "@exodus/bytes": "^1.6.0", + "cssstyle": "^5.3.4", + "data-urls": "^6.0.0", + "decimal.js": "^10.6.0", + "html-encoding-sniffer": "^6.0.0", + "http-proxy-agent": "^7.0.2", + "https-proxy-agent": "^7.0.6", + "is-potential-custom-element-name": "^1.0.1", + "parse5": "^8.0.0", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^6.0.0", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^8.0.0", + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^15.1.0", + "ws": "^8.18.3", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "canvas": "^3.0.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/lz-string": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", + "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", + "dev": true, + "license": "MIT", + "peer": true, + "bin": { + "lz-string": "bin/bin.js" + } + }, + "node_modules/magic-string": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.4.3.tgz", + "integrity": "sha512-z12OxmaPGE0F4xlpGdjuAUckipLpQlTAuAmyGhNoD7ODpYUzvDfvtxUbjM/jwznyP178oju/KDdyi220wNJ0RQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.6.0" + } + }, + "node_modules/mdn-data": { + "version": "2.27.1", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", + "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", + "dev": true, + "license": "CC0-1.0" + }, + "node_modules/min-indent": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", + "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.20", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.20.tgz", + "integrity": "sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/node-releases": { + "version": "2.0.58", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.58.tgz", + "integrity": "sha512-BdiKAgbfRlvfMq/XzO/AF9NPmaA8a+YpLAyAFTVFBj3gARrAIfWvNq7Q7NE4se3KgFu0KkrvQMxTS0XVnsGzHg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/obug": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/obug/-/obug-3.0.0.tgz", + "integrity": "sha512-5vvB5+W7ePv+p3uqxi+RcW1XAzLW0/hxt3/4X4Lc4qHudzOhmBiBwOY6DRob4WnanAEGvNcLjF+KNOufrUoEQw==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, + "node_modules/openapi-typescript": { + "version": "7.13.0", + "resolved": "https://registry.npmjs.org/openapi-typescript/-/openapi-typescript-7.13.0.tgz", + "integrity": "sha512-EFP392gcqXS7ntPvbhBzbF8TyBA+baIYEm791Hy5YkjDYKTnk/Tn5OQeKm5BIZvJihpp8Zzr4hzx0Irde1LNGQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@redocly/openapi-core": "^1.34.6", + "ansi-colors": "^4.1.3", + "change-case": "^5.4.4", + "parse-json": "^8.3.0", + "supports-color": "^10.2.2", + "yargs-parser": "^21.1.1" + }, + "bin": { + "openapi-typescript": "bin/cli.js" + }, + "peerDependencies": { + "typescript": "^5.x" + } + }, + "node_modules/openapi-typescript/node_modules/supports-color": { + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-10.2.2.tgz", + "integrity": "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/parse-json": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-8.3.0.tgz", + "integrity": "sha512-ybiGyvspI+fAoRQbIPRddCcSTV9/LsJbf0e/S85VLowVGzRmokfneg2kwVW/KU5rOXrPSbF1qAKPMgNTqqROQQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.26.2", + "index-to-position": "^1.1.0", + "type-fest": "^4.39.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parse5": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz", + "integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^8.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/playwright": { + "version": "1.64.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.64.0.tgz", + "integrity": "sha512-kVzTnYFYEyQ9RGzBjNCd2+yEqWmVbxl3AHPynv3qQ6JTjv4a+kwRcnqQwIpuP3NnrnIrE/Q6wWAIAvMfIE2/Yg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.64.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/playwright-core": { + "version": "1.64.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.64.0.tgz", + "integrity": "sha512-T9r+MZkTECl2+oUcZ26YgZzTrYidTsIhYYkJix+6iDOymOb1FB2RLLSOxRGnZGv5DStt+aduIVbt+E8WygXYpg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/pluralize": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/pluralize/-/pluralize-8.0.0.tgz", + "integrity": "sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/postcss": { + "version": "8.5.30", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.30.tgz", + "integrity": "sha512-zkd+e9S1AQHhmaBKye9zlFHWjOwVgqXl1q3ECYy8561K7qxR2cREf/STLskulBFd78k6AOFeA+ff1TtEzk9bAA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.20", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.2" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/pretty-format": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", + "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/pretty-format/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/react": { + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/react/-/react-19.3.0.tgz", + "integrity": "sha512-E8LUcbtBWt20bbl2YoHfx4ZDBdxVTfOKtCZn9cDSJ4l6/nuoApcpIBcj47t2wZoVX8g2ZHuMHbiShgCR1T5Sog==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.3.0.tgz", + "integrity": "sha512-JDk8dgif51OjFoDE70+OT9ICyYr+69HlmihNwp1+Nsfbna3t5sIiCa9ZJktDmQ4/1b/rn26hIAR2uYXDMr5r0Q==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.28.0" + }, + "peerDependencies": { + "react": "^19.3.0" + } + }, + "node_modules/react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/react-refresh": { + "version": "0.18.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.18.0.tgz", + "integrity": "sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-router": { + "version": "7.18.4", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.18.4.tgz", + "integrity": "sha512-PUPQcMhMGRAslLcvtlPz/kmzBEWPhLdgLFrL7pLNepBL6dX0lWj4WD2cUYVgYCuT3jxvghYFg81cDTj44DhetQ==", + "license": "MIT", + "dependencies": { + "cookie": "^1.0.1", + "set-cookie-parser": "^2.6.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } + } + }, + "node_modules/redent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", + "integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "indent-string": "^4.0.0", + "strip-indent": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/rollup": { + "version": "4.64.6", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.64.6.tgz", + "integrity": "sha512-fX1Vy+UNKdXn+xKDnu/HBXbHVr1AY8cSdQj9BU4kLv5aFCMqGpvrVbyUMmjN3DdzWhSAuTbcX8PCNEVaumthYA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@napi-rs/lzma-linux-x64-gnu": "1.5.1", + "@rollup/rollup-android-arm-eabi": "4.64.6", + "@rollup/rollup-android-arm64": "4.64.6", + "@rollup/rollup-darwin-arm64": "4.64.6", + "@rollup/rollup-darwin-x64": "4.64.6", + "@rollup/rollup-freebsd-arm64": "4.64.6", + "@rollup/rollup-freebsd-x64": "4.64.6", + "@rollup/rollup-linux-arm-gnueabihf": "4.64.6", + "@rollup/rollup-linux-arm-musleabihf": "4.64.6", + "@rollup/rollup-linux-arm64-gnu": "4.64.6", + "@rollup/rollup-linux-arm64-musl": "4.64.6", + "@rollup/rollup-linux-loong64-gnu": "4.64.6", + "@rollup/rollup-linux-loong64-musl": "4.64.6", + "@rollup/rollup-linux-ppc64-gnu": "4.64.6", + "@rollup/rollup-linux-ppc64-musl": "4.64.6", + "@rollup/rollup-linux-riscv64-gnu": "4.64.6", + "@rollup/rollup-linux-riscv64-musl": "4.64.6", + "@rollup/rollup-linux-s390x-gnu": "4.64.6", + "@rollup/rollup-linux-x64-gnu": "4.64.6", + "@rollup/rollup-linux-x64-musl": "4.64.6", + "@rollup/rollup-openbsd-x64": "4.64.6", + "@rollup/rollup-openharmony-arm64": "4.64.6", + "@rollup/rollup-win32-arm64-msvc": "4.64.6", + "@rollup/rollup-win32-ia32-msvc": "4.64.6", + "@rollup/rollup-win32-x64-gnu": "4.64.6", + "@rollup/rollup-win32-x64-msvc": "4.64.6", + "fsevents": "~2.3.2" + } + }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, + "node_modules/scheduler": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.28.0.tgz", + "integrity": "sha512-juorfCmIkIw8tT+p5BXSm6PJjQF/ycEYmKyzURCIt/RaZIhL+PulbQ9Yu2z1HdOJDdqDTlxA1+xKBmHXJsczAw==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/set-cookie-parser": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", + "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", + "license": "MIT" + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/source-map-js": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz", + "integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/std-env": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.3.0.tgz", + "integrity": "sha512-OtU/EgQ1kIm5KwqQpBC6ZEMXrZRui11w8zgfTWp8cdO9B8OaPsbA8bTHO2P+HNo1VlUTGMVBwPhydu6poeXiag==", + "dev": true, + "license": "MIT" + }, + "node_modules/strip-indent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz", + "integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "min-indent": "^1.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinybench": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-6.2.2.tgz", + "integrity": "sha512-nIZ2N2dFpjBsg2hgitKu7mA+FvsIJuGJOwysRngmVRRmWgR/UyPjwJj8c/fT/dhm8EkHnF5EOogRf+0Z2qrimA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/tinyexec": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.1.tgz", + "integrity": "sha512-GCvB3aoys96IuDFBMcTB46JOR6mdMtAToqwiW8JlWhsoh1mhHi/xn9ss/Dg7N555GiJyEt2qzoG/NHCwM6h1EA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tldts": { + "version": "7.4.18", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.18.tgz", + "integrity": "sha512-somxA8xKAUzlAxl0TX/jMSYIbRNFIVGnI96pVGJPeMc5BsHmfVXn6mOJT6U++ELnl04eGfzpfcEuZe8WiASkXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^7.4.18" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "7.4.18", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.18.tgz", + "integrity": "sha512-KI2PKvCmvQI/xD6ypPqkU0i7oxNXR/O58RLRaHR4svsgVUQ2JE/XC8BtMbG6PSehD27I8sZJRcC1ASnCy6lp3g==", + "dev": true, + "license": "MIT" + }, + "node_modules/tough-cookie": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.2.tgz", + "integrity": "sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^7.0.5" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", + "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/ts-api-utils": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", + "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/type-fest": { + "version": "4.41.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz", + "integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/typescript-eslint": { + "version": "8.71.2", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.71.2.tgz", + "integrity": "sha512-Rz4effbMy2g9wnvOJZG6SbVavX313Lap1vNEbk1RUochzickvzP8P4HB+bkNg5e50eVz1hQGjh9K1omI1xB4Bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/eslint-plugin": "8.71.2", + "@typescript-eslint/parser": "8.71.2", + "@typescript-eslint/typescript-estree": "8.71.2", + "@typescript-eslint/utils": "8.71.2" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/undici-types": { + "version": "7.24.6", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz", + "integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==", + "dev": true, + "license": "MIT" + }, + "node_modules/update-browserslist-db": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.4.tgz", + "integrity": "sha512-t8wonIOdQyOjtPd2VktlBu6QjTVTks6v8bSi7kd0r2Vll8TK1JYDZtRRdbmCHOAMphkZgrzK+L+vXK4pyZ8t4A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/uri-js-replace": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/uri-js-replace/-/uri-js-replace-1.0.1.tgz", + "integrity": "sha512-W+C9NWNLFOoBI2QWDp4UT9pv65r2w5Cx+3sTYFvtMdDBxkKt1syCqsUdSFAChbEe1uK5TfS04wt/nGwmaeIQ0g==", + "dev": true, + "license": "MIT" + }, + "node_modules/vite": { + "version": "7.3.7", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.7.tgz", + "integrity": "sha512-AHEzTORMbi7/7aXp7w7KZ5+DCUdSeLsJz0grY8xHpc9DHdYb0vQu6DqJfm2tOw3gDExNgPgnJTXxM6syYFHaCA==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.27.0 || ^0.28.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vitest": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-5.0.4.tgz", + "integrity": "sha512-z0WxZRV2Sg48wF0FSgMq3TFG8JS38/Qr96vZVZdX6+Em9raQTvnXQvdTFJe2m3rfyJnEHeHQ/snceIdi8D1pVg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/mocker": "5.0.4", + "chai": "^6.3.0", + "es-module-lexer": "^2.3.2", + "expect-type": "^1.4.0", + "magic-string": "^1.4.3", + "obug": "^3.0.0", + "picomatch": "^4.0.7", + "std-env": "^4.3.0", + "tinybench": "^6.2.2", + "tinyexec": "^1.3.1", + "tinyglobby": "^0.2.17", + "why-is-node-running": "3.2.1" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^22.12.0 || ^24.0.0 || >=26.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "5.0.4", + "@vitest/browser-preview": "5.0.4", + "@vitest/browser-webdriverio": "^5.0.0-beta.5 || >=5.0.0", + "@vitest/coverage-istanbul": "5.0.4", + "@vitest/coverage-v8": "5.0.4", + "@vitest/ui": "5.0.4", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.4.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/webidl-conversions": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", + "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-mimetype": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", + "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-url": { + "version": "15.1.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-15.1.0.tgz", + "integrity": "sha512-2ytDk0kiEj/yu90JOAp44PVPUkO9+jVhyf+SybKlRHSDlvOOZhdPIrr7xTH64l4WixO2cP+wQIcgujkGBPPz6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/why-is-node-running": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-3.2.1.tgz", + "integrity": "sha512-Tb2FUhB4vUsGQlfSquQLYkApkuPAFQXGFzxWKHHumVz2dK+X1RUm/HnID4+TfIGYJ1kTcwOaCk/buYCEJr6YjQ==", + "dev": true, + "license": "MIT", + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=20.11" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/ws": { + "version": "8.22.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.22.0.tgz", + "integrity": "sha512-Ydggc987+RO0AnWtZ/7Wq9FtNvcrL1b/RO0ud9mWjUPgDrsAAwQSF51sm2hm1XofbU/4jkpGEsLFsZZxU+1DOg==", + "dev": true, + "license": "MIT", + "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 + } + } + }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + }, + "node_modules/yaml-ast-parser": { + "version": "0.0.43", + "resolved": "https://registry.npmjs.org/yaml-ast-parser/-/yaml-ast-parser-0.0.43.tgz", + "integrity": "sha512-2PTINUwsRqSd+s8XxKaJWQlUuEMHJQyEuh2edBbW8KNJz0SJPwUSD2zRWqezFEdN7IzAgeuYHFUCF7o8zRdZ0A==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + } + } +} diff --git a/web/package.json b/web/package.json new file mode 100644 index 0000000..b0836e5 --- /dev/null +++ b/web/package.json @@ -0,0 +1,42 @@ +{ + "name": "gewu-web", + "private": true, + "version": "0.1.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "check": "tsc -b && eslint . && vitest run", + "test": "vitest run", + "test:e2e": "playwright test", + "generate:api-types": "openapi-typescript ../schema/v1/openapi.json --output src/generated/api.ts", + "preview": "vite preview" + }, + "dependencies": { + "@tanstack/react-query": "^5.90.0", + "react": "^19.2.0", + "react-dom": "^19.2.0", + "react-router": "^7.9.0" + }, + "devDependencies": { + "@eslint/js": "^9.37.0", + "@playwright/test": "^1.64.0", + "@testing-library/jest-dom": "^6.9.0", + "@testing-library/react": "^16.3.0", + "@testing-library/user-event": "^14.6.1", + "@types/node": "^24.7.0", + "@types/react": "^19.2.0", + "@types/react-dom": "^19.2.0", + "@vitejs/plugin-react": "^5.0.4", + "eslint": "^9.37.0", + "eslint-plugin-react-hooks": "^5.2.0", + "eslint-plugin-react-refresh": "^0.4.23", + "globals": "^16.4.0", + "jsdom": "^27.0.0", + "openapi-typescript": "^7.13.0", + "typescript": "~5.9.3", + "typescript-eslint": "^8.46.0", + "vite": "^7.1.10", + "vitest": "^5.0.4" + } +} diff --git a/web/playwright.config.ts b/web/playwright.config.ts new file mode 100644 index 0000000..8b3b818 --- /dev/null +++ b/web/playwright.config.ts @@ -0,0 +1,22 @@ +import { defineConfig, devices } from '@playwright/test'; + +export default defineConfig({ + testDir: './e2e', + fullyParallel: true, + forbidOnly: Boolean(process.env.CI), + retries: process.env.CI ? 1 : 0, + reporter: process.env.CI ? 'github' : 'list', + use: { + baseURL: 'http://127.0.0.1:4173', + trace: 'retain-on-failure', + }, + projects: [ + { name: 'chromium', use: { ...devices['Desktop Chrome'] } }, + ], + webServer: { + command: 'npm run dev -- --host 127.0.0.1 --port 4173 --strictPort', + url: 'http://127.0.0.1:4173', + reuseExistingServer: !process.env.CI, + timeout: 30_000, + }, +}); diff --git a/web/src/generated/api.ts b/web/src/generated/api.ts new file mode 100644 index 0000000..8a4bf37 --- /dev/null +++ b/web/src/generated/api.ts @@ -0,0 +1,4579 @@ +/** + * This file was auto-generated by openapi-typescript. + * Do not make direct changes to the file. + */ + +export interface paths { + "/auth/owner-session": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Create Owner Session */ + post: operations["owner_session_create"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/health": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Health */ + get: operations["health"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v1/admin/credentials": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Credentials */ + get: operations["admin_list_credentials"]; + put?: never; + /** Create */ + post: operations["admin_create_credential"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v1/admin/credentials/{credential_id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post?: never; + /** Revoke */ + delete: operations["admin_revoke_credential"]; + options?: never; + head?: never; + /** Update Credential */ + patch: operations["admin_update_credential"]; + trace?: never; + }; + "/v1/admin/events": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Events */ + get: operations["admin_list_events"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v1/admin/projects": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Admin List Projects */ + get: operations["admin_list_projects"]; + put?: never; + /** Project Create */ + post: operations["project_create"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v1/admin/projects/{project_id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + /** Project Update Content */ + patch: operations["project_update_content"]; + trace?: never; + }; + "/v1/admin/projects/{project_id}/archive": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Project Archive */ + post: operations["project_archive"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v1/admin/users": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Users */ + get: operations["admin_list_accounts"]; + put?: never; + /** Create User */ + post: operations["admin_create_account"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v1/admin/users/{username}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post?: never; + /** Disable User */ + delete: operations["admin_disable_account"]; + options?: never; + head?: never; + /** Update User */ + patch: operations["admin_update_account"]; + trace?: never; + }; + "/v1/diagnostics": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Diagnostics */ + get: operations["diagnostics"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v1/me": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Me */ + get: operations["account_current"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v1/me/agent-credentials": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Agents */ + get: operations["account_list_agent_credentials"]; + put?: never; + /** Create Agent */ + post: operations["account_create_agent_credential"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v1/me/agent-credentials/{credential_id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post?: never; + /** Revoke Agent */ + delete: operations["account_revoke_agent_credential"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v1/me/password": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Change Password */ + post: operations["account_change_password"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v1/neighbors": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Neighbors */ + post: operations["neighbors"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v1/projects": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** List Projects */ + get: operations["list_projects"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v1/projects/{project_id}/nodes/{node_id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get Node */ + get: operations["get_node"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v1/projects/{project_id}/nodes/{node_id}/history": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get Node History */ + get: operations["node_history"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v1/projects/{project_id}/sources/{revision_id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get Source */ + get: operations["get_source"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v1/projects/{project_id}/sources/{revision_id}/artifact": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get Source Artifact */ + get: operations["get_source_artifact"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v1/projects/{project_id}/sources/{revision_id}/extractions/{extraction_id}/blocks/{block_id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get Source Location */ + get: operations["get_source_location"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v1/search": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Search */ + post: operations["search"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v1/session": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Restore Browser Session */ + get: operations["browser_session_restore"]; + put?: never; + /** Create Browser Session */ + post: operations["browser_session_create"]; + /** Revoke Browser Session */ + delete: operations["browser_session_revoke"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v1/sources": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** List Sources */ + post: operations["list_sources"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v1/users/session": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Login */ + post: operations["account_login"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; +} +export type webhooks = Record; +export interface components { + schemas: { + /** AcceptanceManifest */ + AcceptanceManifest: { + /** + * Accepted Node Ids + * @default [] + */ + accepted_node_ids: string[]; + /** Actor Id */ + actor_id: string; + /** Before Revision */ + before_revision: string; + /** Changed Files */ + changed_files: components["schemas"]["AcceptanceManifest__ChangedFile"][]; + /** Credential Id */ + credential_id: string; + /** + * Decided At + * Format: date-time + */ + decided_at: string; + /** + * Owner Decisions + * @default [] + */ + owner_decisions: components["schemas"]["AcceptanceManifest__OwnerDecisionReference"][]; + /** Payload Digest */ + payload_digest: string; + /** Policy Bindings */ + policy_bindings: components["schemas"]["AcceptanceManifest__PolicyBinding"][]; + /** Proposal Id */ + proposal_id: string; + /** Proposal Revision */ + proposal_revision: number; + /** + * Schema Version + * @default 1 + * @constant + */ + schema_version: 1; + }; + /** ChangedFile */ + AcceptanceManifest__ChangedFile: { + /** Content Sha256 */ + content_sha256: string; + /** Path */ + path: string; + /** + * Schema Version + * @default 1 + * @constant + */ + schema_version: 1; + }; + /** OwnerDecisionReference */ + AcceptanceManifest__OwnerDecisionReference: { + /** Challenge Id */ + challenge_id: string; + /** + * Decided At + * Format: date-time + */ + decided_at: string; + /** + * Decision + * @default authorize + * @constant + */ + decision: "authorize"; + /** Decision Digest */ + decision_digest: string; + /** Domains */ + domains: string[]; + /** Grant Version */ + grant_version: number; + /** Owner Principal Id */ + owner_principal_id: string; + /** Owner Session Id */ + owner_session_id: string; + /** Rationale */ + rationale: string; + /** + * Schema Version + * @default 1 + * @constant + */ + schema_version: 1; + }; + /** PolicyBinding */ + AcceptanceManifest__PolicyBinding: { + /** Policy Id */ + policy_id: string; + /** Policy Version */ + policy_version: number; + /** Project Id */ + project_id: string; + /** + * Schema Version + * @default 1 + * @constant + */ + schema_version: 1; + }; + /** AccountCreateBody */ + AccountCreateBody: { + /** Password */ + password: string; + /** Project Ids */ + project_ids?: string[]; + /** + * Role + * @default editor + * @enum {string} + */ + role: "viewer" | "editor" | "admin"; + /** Username */ + username: string; + }; + /** AccountSummary */ + AccountSummary: { + /** Active */ + active: boolean; + /** Principal Id */ + principal_id: string; + /** Project Ids */ + project_ids: string[]; + /** Role */ + role: string; + /** Username */ + username: string; + }; + /** AccountUpdateBody */ + AccountUpdateBody: { + /** Project Ids */ + project_ids?: string[]; + /** + * Role + * @enum {string} + */ + role: "viewer" | "editor" | "admin"; + }; + /** AdminEvent */ + AdminEvent: { + /** Action */ + action: string; + /** Actor */ + actor: string; + /** Credential Id */ + credential_id: string; + /** Event Id */ + event_id: number; + /** Occurred At */ + occurred_at: string; + }; + /** AgentCreateBody */ + AgentCreateBody: { + /** Name */ + name: string; + /** Project Ids */ + project_ids: string[]; + }; + /** AutoAcceptPolicy */ + AutoAcceptPolicy: { + /** + * Automatic Edge Types + * @default [] + */ + automatic_edge_types: ("solves" | "addresses" | "causes" | "supports" | "contradicts" | "instance_of" | "part_of" | "precedes" | "supersedes" | "compared_with" | "derived_from" | "applies_to")[]; + /** + * Automatic Node Types + * @default [] + */ + automatic_node_types: ("finding" | "problem" | "technique" | "case" | "entity" | "event" | "concept" | "datapoint" | "synthesis")[]; + /** + * Automatic Source Registration + * @default false + */ + automatic_source_registration: boolean; + /** Policy Id */ + policy_id: string; + /** Policy Version */ + policy_version: number; + /** Project Id */ + project_id: string; + /** + * Require Owner Decision For + * @default [ + * "delete", + * "merge", + * "promote", + * "intent", + * "policy" + * ] + */ + require_owner_decision_for: ("delete" | "merge" | "promote" | "intent" | "policy")[]; + /** + * Require Supported Assessment + * @default true + */ + require_supported_assessment: boolean; + /** + * Schema Version + * @default 1 + * @constant + */ + schema_version: 1; + }; + /** BudgetProfile */ + BudgetProfile: { + /** Encoding */ + encoding: string; + /** + * Schema Version + * @default 1 + * @constant + */ + schema_version: 1; + /** Tokenizer */ + tokenizer: string; + }; + /** + * Confidence + * @enum {string} + */ + Confidence: "low" | "medium" | "high"; + /** CredentialCreateBody */ + CredentialCreateBody: { + /** Credential Id */ + credential_id: string; + /** Principal Id */ + principal_id: string; + /** Project Ids */ + project_ids?: string[]; + /** + * Role + * @enum {string} + */ + role: "viewer" | "editor" | "agent" | "admin"; + }; + /** CredentialSummary */ + CredentialSummary: { + /** Credential Id */ + credential_id: string; + /** Owner Principal Id */ + owner_principal_id?: string | null; + /** Principal Id */ + principal_id: string; + /** Project Ids */ + project_ids: string[]; + /** Revoked */ + revoked: boolean; + /** Roles */ + roles: string[]; + }; + /** CredentialUpdateBody */ + CredentialUpdateBody: { + /** Project Ids */ + project_ids?: string[]; + /** + * Role + * @enum {string} + */ + role: "viewer" | "editor" | "agent" | "admin"; + }; + /** CurrentUser */ + CurrentUser: { + /** Password Account */ + password_account: boolean; + /** Principal Id */ + principal_id: string; + /** Project Ids */ + project_ids: string[]; + /** Roles */ + roles: string[]; + }; + /** DomainError */ + DomainError: { + code: components["schemas"]["DomainError__ErrorCode"]; + /** + * Findings + * @default [] + */ + findings: components["schemas"]["DomainError__Finding"][]; + /** Message */ + message: string; + /** Request Id */ + request_id: string; + /** Retryable */ + retryable: boolean; + /** + * Schema Version + * @default 1 + * @constant + */ + schema_version: 1; + }; + /** + * ErrorCode + * @enum {string} + */ + DomainError__ErrorCode: "SCOPE_REQUIRED" | "OUT_OF_SCOPE" | "UNAUTHORIZED" | "OWNER_DECISION_REQUIRED" | "POLICY_MISSING" | "EVIDENCE_MISMATCH" | "SUPPORT_UNAVAILABLE" | "BASE_CONFLICT" | "IDEMPOTENCY_CONFLICT" | "BUDGET_TOO_SMALL" | "UPLOAD_EXPIRED" | "BUSY" | "RECOVERY_REQUIRED"; + /** Finding */ + DomainError__Finding: { + /** Code */ + code: string; + /** Message */ + message: string; + /** + * Path + * @default null + */ + path: string | null; + /** + * Schema Version + * @default 1 + * @constant + */ + schema_version: 1; + }; + /** EvidenceReference */ + EvidenceReference: { + /** Assertion Digest */ + assertion_digest: string; + /** Assertion Path */ + assertion_path: string; + /** Extraction Id */ + extraction_id: string; + locator: components["schemas"]["Locator"]; + /** + * Normalization Profile + * @constant + */ + normalization_profile: "unicode-nfc-lf-v1"; + /** Quote */ + quote: string; + /** + * Schema Version + * @default 1 + * @constant + */ + schema_version: 1; + /** Source Revision Id */ + source_revision_id: string; + }; + /** ExtractionRecord */ + ExtractionRecord: { + /** Blocks */ + blocks: components["schemas"]["ExtractionRecord__ExtractionBlock"][]; + /** Extraction Id */ + extraction_id: string; + /** + * Normalization Profile + * @constant + */ + normalization_profile: "unicode-nfc-lf-v1"; + /** Parser Config Sha256 */ + parser_config_sha256: string; + /** Parser Name */ + parser_name: string; + /** Parser Version */ + parser_version: string; + /** + * Schema Version + * @default 1 + * @constant + */ + schema_version: 1; + /** Source Revision Id */ + source_revision_id: string; + /** + * Status + * @enum {string} + */ + status: "complete" | "partial" | "unavailable"; + /** + * Warnings + * @default [] + */ + warnings: string[]; + }; + /** ExtractionBlock */ + ExtractionRecord__ExtractionBlock: { + /** Block Id */ + block_id: string; + /** Normalization Map */ + normalization_map: [ + number, + number + ][]; + /** Normalized Text */ + normalized_text: string; + /** Ordinal */ + ordinal: number; + /** Original Text */ + original_text: string; + /** + * Page + * @default null + */ + page: number | null; + /** + * Schema Version + * @default 1 + * @constant + */ + schema_version: 1; + /** + * Time End Ms + * @default null + */ + time_end_ms: number | null; + /** + * Time Start Ms + * @default null + */ + time_start_ms: number | null; + }; + /** Finding */ + Finding: { + /** Code */ + code: string; + /** Message */ + message: string; + /** Path */ + path?: string | null; + /** + * Schema Version + * @default 1 + * @constant + */ + schema_version: 1; + }; + /** HTTPValidationError */ + HTTPValidationError: { + /** Detail */ + detail?: components["schemas"]["ValidationError"][]; + }; + /** IssuedCredential */ + IssuedCredential: { + /** Credential Id */ + credential_id: string; + /** Token */ + token: string; + }; + /** KnowledgeNode */ + KnowledgeNode: { + /** Agent Id */ + agent_id: string; + /** + * Aliases + * @default [] + */ + aliases: string[]; + /** + * As Of + * @default null + */ + as_of: string | null; + /** + * Assumptions + * @default [] + */ + assumptions: components["schemas"]["KnowledgeNode__Claim"][]; + /** + * Audience + * @default [] + */ + audience: string[]; + /** + * Claim Support + * @enum {string} + */ + claim_support: "pending" | "supported" | "unsupported"; + /** Claims */ + claims: components["schemas"]["KnowledgeNode__Claim"][]; + confidence: components["schemas"]["KnowledgeNode__Confidence"]; + /** + * Created + * Format: date-time + */ + created: string; + /** + * Demographic + * @default null + */ + demographic: string | null; + /** + * Domain + * @default [] + */ + domain: string[]; + /** Lens Fit */ + lens_fit?: { + [key: string]: components["schemas"]["KnowledgeNode__LensFit"]; + }; + lifecycle: components["schemas"]["KnowledgeNode__Lifecycle"]; + /** + * Limitations + * @default [] + */ + limitations: components["schemas"]["KnowledgeNode__Claim"][]; + /** + * Merged From + * @default [] + */ + merged_from: string[]; + /** Model Id */ + model_id: string; + /** Node Id */ + node_id: string; + node_type: components["schemas"]["KnowledgeNode__NodeType"]; + /** Project Id */ + project_id: string; + /** + * Reporting Year + * @default null + */ + reporting_year: number | null; + review_state: components["schemas"]["KnowledgeNode__ReviewState"]; + /** Run Id */ + run_id: string; + /** + * Sample Size + * @default null + */ + sample_size: number | null; + /** + * Schema Version + * @default 1 + * @constant + */ + schema_version: 1; + /** + * Stale After + * @default null + */ + stale_after: string | null; + /** Summary */ + summary: string; + /** + * Superseded By + * @default [] + */ + superseded_by: string[]; + /** + * Supersedes + * @default [] + */ + supersedes: string[]; + /** + * Tags + * @default [] + */ + tags: string[]; + /** Title */ + title: string; + /** + * Unit + * @default null + */ + unit: string | null; + /** + * Updated + * Format: date-time + */ + updated: string; + /** + * Use For + * @default [] + */ + use_for: string[]; + /** + * Value + * @default null + */ + value: number | null; + }; + /** Claim */ + KnowledgeNode__Claim: { + assessment: components["schemas"]["KnowledgeNode__SupportAssessment"]; + /** Claim Id */ + claim_id: string; + /** Evidence */ + evidence: components["schemas"]["KnowledgeNode__EvidenceReference"][]; + /** + * Evidence Type + * @enum {string} + */ + evidence_type: "measured" | "reported" | "opinion" | "vendor_marketing"; + /** + * Schema Version + * @default 1 + * @constant + */ + schema_version: 1; + /** Text */ + text: string; + }; + /** + * Confidence + * @enum {string} + */ + KnowledgeNode__Confidence: "low" | "medium" | "high"; + /** EvidenceReference */ + KnowledgeNode__EvidenceReference: { + /** Assertion Digest */ + assertion_digest: string; + /** Assertion Path */ + assertion_path: string; + /** Extraction Id */ + extraction_id: string; + locator: components["schemas"]["KnowledgeNode__Locator"]; + /** + * Normalization Profile + * @constant + */ + normalization_profile: "unicode-nfc-lf-v1"; + /** Quote */ + quote: string; + /** + * Schema Version + * @default 1 + * @constant + */ + schema_version: 1; + /** Source Revision Id */ + source_revision_id: string; + }; + /** LensFit */ + KnowledgeNode__LensFit: { + /** + * Horizon + * @enum {string} + */ + horizon: "H1" | "H2" | "H3"; + /** + * Key Question + * @default null + */ + key_question: string | null; + /** Reason */ + reason: string; + /** + * Schema Version + * @default 1 + * @constant + */ + schema_version: 1; + /** Score */ + score: number; + }; + /** + * Lifecycle + * @enum {string} + */ + KnowledgeNode__Lifecycle: "active" | "superseded"; + /** Locator */ + KnowledgeNode__Locator: { + /** Block Id */ + block_id: string; + /** End */ + end: number; + /** + * Kind + * @enum {string} + */ + kind: "text_span" | "pdf_page_span" | "transcript_timestamp_span"; + /** + * Page + * @default null + */ + page: number | null; + /** + * Schema Version + * @default 1 + * @constant + */ + schema_version: 1; + /** Start */ + start: number; + /** + * Time End Ms + * @default null + */ + time_end_ms: number | null; + /** + * Time Start Ms + * @default null + */ + time_start_ms: number | null; + }; + /** + * NodeType + * @enum {string} + */ + KnowledgeNode__NodeType: "finding" | "problem" | "technique" | "case" | "entity" | "event" | "concept" | "datapoint" | "synthesis"; + /** + * ReviewState + * @enum {string} + */ + KnowledgeNode__ReviewState: "draft" | "reviewed"; + /** SupportAssessment */ + KnowledgeNode__SupportAssessment: { + /** Assertion Digest */ + assertion_digest: string; + /** Assessment Id */ + assessment_id: string; + /** Assessor Id */ + assessor_id: string; + /** Evidence Digests */ + evidence_digests: string[]; + /** Method */ + method: string; + /** Rationale */ + rationale: string; + /** Run Id */ + run_id: string; + /** + * Schema Version + * @default 1 + * @constant + */ + schema_version: 1; + status: components["schemas"]["KnowledgeNode__SupportStatus"]; + }; + /** + * SupportStatus + * @enum {string} + */ + KnowledgeNode__SupportStatus: "supported" | "unsupported" | "unavailable"; + /** Locator */ + Locator: { + /** Block Id */ + block_id: string; + /** End */ + end: number; + /** + * Kind + * @enum {string} + */ + kind: "text_span" | "pdf_page_span" | "transcript_timestamp_span"; + /** Page */ + page?: number | null; + /** + * Schema Version + * @default 1 + * @constant + */ + schema_version: 1; + /** Start */ + start: number; + /** Time End Ms */ + time_end_ms?: number | null; + /** Time Start Ms */ + time_start_ms?: number | null; + }; + /** LoginBody */ + LoginBody: { + /** Password */ + password: string; + /** Username */ + username: string; + }; + /** NeighborRequest */ + NeighborRequest: { + /** Budget Tokens */ + budget_tokens: number; + /** + * Cursor + * @default null + */ + cursor: string | null; + /** + * Edge Types + * @default [] + */ + edge_types: ("solves" | "addresses" | "causes" | "supports" | "contradicts" | "instance_of" | "part_of" | "precedes" | "supersedes" | "compared_with" | "derived_from" | "applies_to")[]; + /** + * Limit + * @default 20 + */ + limit: number; + /** Node Id */ + node_id: string; + /** + * Schema Version + * @default 1 + * @constant + */ + schema_version: 1; + scope: components["schemas"]["NeighborRequest__Scope"]; + }; + /** NeighborRequestBody */ + NeighborRequestBody: { + /** Budget Tokens */ + budget_tokens: number; + /** Cursor */ + cursor?: string | null; + /** Edge Types */ + edge_types?: string[]; + /** + * Limit + * @default 20 + */ + limit: number; + /** Node Id */ + node_id: string; + scope: components["schemas"]["SearchScopeBody"]; + }; + /** + * Scope + * @description Project context plus optional project includes; all-project access is deliberate. + */ + NeighborRequest__Scope: { + /** + * All Projects + * @default false + */ + all_projects: boolean; + /** + * Include Projects + * @default null + */ + include_projects: string[] | null; + /** + * Include Shared + * @default true + */ + include_shared: boolean; + /** Projects */ + projects: string[]; + /** + * Schema Version + * @default 1 + * @constant + */ + schema_version: 1; + }; + /** + * OwnerSessionRequestBody + * @description Short-lived owner authentication inputs; never accepted as a normal session. + */ + OwnerSessionRequestBody: { + /** Challenge Id */ + challenge_id: string; + /** Password */ + password: string; + /** Principal Id */ + principal_id: string; + /** Totp Code */ + totp_code: string; + }; + /** PasswordChangeBody */ + PasswordChangeBody: { + /** Current Password */ + current_password: string; + /** New Password */ + new_password: string; + }; + /** + * ProjectContentUpdateBody + * @description Editable human-authored project intent fields. + */ + ProjectContentUpdateBody: { + /** Description */ + description: string; + /** Key Questions */ + key_questions: string[]; + /** North Star */ + north_star: string; + }; + /** + * ProjectCreateBody + * @description An administrator supplies the human-owned project lens and its initial policy. + */ + ProjectCreateBody: { + policy: components["schemas"]["AutoAcceptPolicy"]; + project: components["schemas"]["ProjectRecord"]; + }; + /** ProjectRecord */ + ProjectRecord: { + /** Autonomy Policy Id */ + autonomy_policy_id: string; + /** + * Default Includes + * @default [ + * "shared" + * ] + */ + default_includes: string[]; + /** + * Description + * @default + */ + description: string; + /** Key Questions */ + key_questions: string[]; + /** North Star */ + north_star: string; + /** + * Owners + * @default [] + */ + owners: string[]; + /** Project Id */ + project_id: string; + /** + * Schema Version + * @default 1 + * @constant + */ + schema_version: 1; + status: components["schemas"]["ProjectStatus"]; + /** Taxonomy Extensions */ + taxonomy_extensions?: { + [key: string]: string[]; + }; + /** Title */ + title: string; + /** + * Watch List + * @default [] + */ + watch_list: string[]; + }; + /** + * ProjectStatus + * @enum {string} + */ + ProjectRecord__ProjectStatus: "active" | "paused" | "archived"; + /** + * ProjectStatus + * @enum {string} + */ + ProjectStatus: "active" | "paused" | "archived"; + /** ProposalReceipt */ + ProposalReceipt: { + /** + * Accepted Node Ids + * @default [] + */ + accepted_node_ids: string[]; + /** + * Commit Id + * @default null + */ + commit_id: string | null; + /** + * Decided At + * @default null + */ + decided_at: string | null; + /** + * Findings + * @default [] + */ + findings: components["schemas"]["ProposalReceipt__Finding"][]; + /** + * Outcome + * @enum {string} + */ + outcome: "accepted" | "held" | "rejected" | "validation-failed"; + /** Payload Digest */ + payload_digest: string; + /** Proposal Id */ + proposal_id: string; + /** Revision */ + revision: number; + /** + * Schema Version + * @default 1 + * @constant + */ + schema_version: 1; + state: components["schemas"]["ProposalReceipt__ProposalState"]; + }; + /** Finding */ + ProposalReceipt__Finding: { + /** Code */ + code: string; + /** Message */ + message: string; + /** + * Path + * @default null + */ + path: string | null; + /** + * Schema Version + * @default 1 + * @constant + */ + schema_version: 1; + }; + /** + * ProposalState + * @enum {string} + */ + ProposalReceipt__ProposalState: "staged" | "validating" | "needs-review" | "accepted" | "rejected" | "withdrawn"; + /** ProposalRevision */ + ProposalRevision: { + /** Base Commit */ + base_commit: string; + /** Initiating Project Id */ + initiating_project_id: string; + /** Operations */ + operations: components["schemas"]["ProposalRevision__ProposalOperation"][]; + /** Payload Digest */ + payload_digest: string; + /** Proposal Id */ + proposal_id: string; + /** Proposer Id */ + proposer_id: string; + /** Revision */ + revision: number; + /** Run Id */ + run_id: string; + /** + * Schema Version + * @default 1 + * @constant + */ + schema_version: 1; + scope: components["schemas"]["ProposalRevision__Scope"]; + }; + /** Claim */ + ProposalRevision__Claim: { + assessment: components["schemas"]["ProposalRevision__SupportAssessment"]; + /** Claim Id */ + claim_id: string; + /** Evidence */ + evidence: components["schemas"]["ProposalRevision__EvidenceReference"][]; + /** + * Evidence Type + * @enum {string} + */ + evidence_type: "measured" | "reported" | "opinion" | "vendor_marketing"; + /** + * Schema Version + * @default 1 + * @constant + */ + schema_version: 1; + /** Text */ + text: string; + }; + /** + * Confidence + * @enum {string} + */ + ProposalRevision__Confidence: "low" | "medium" | "high"; + /** EdgeEndpoint */ + ProposalRevision__EdgeEndpoint: { + /** Id */ + id: string; + /** + * Kind + * @enum {string} + */ + kind: "node" | "source" | "project"; + /** + * Schema Version + * @default 1 + * @constant + */ + schema_version: 1; + }; + /** EvidenceReference */ + ProposalRevision__EvidenceReference: { + /** Assertion Digest */ + assertion_digest: string; + /** Assertion Path */ + assertion_path: string; + /** Extraction Id */ + extraction_id: string; + locator: components["schemas"]["ProposalRevision__Locator"]; + /** + * Normalization Profile + * @constant + */ + normalization_profile: "unicode-nfc-lf-v1"; + /** Quote */ + quote: string; + /** + * Schema Version + * @default 1 + * @constant + */ + schema_version: 1; + /** Source Revision Id */ + source_revision_id: string; + }; + /** KnowledgeNode */ + ProposalRevision__KnowledgeNode: { + /** Agent Id */ + agent_id: string; + /** + * Aliases + * @default [] + */ + aliases: string[]; + /** + * As Of + * @default null + */ + as_of: string | null; + /** + * Assumptions + * @default [] + */ + assumptions: components["schemas"]["ProposalRevision__Claim"][]; + /** + * Audience + * @default [] + */ + audience: string[]; + /** + * Claim Support + * @enum {string} + */ + claim_support: "pending" | "supported" | "unsupported"; + /** Claims */ + claims: components["schemas"]["ProposalRevision__Claim"][]; + confidence: components["schemas"]["ProposalRevision__Confidence"]; + /** + * Created + * Format: date-time + */ + created: string; + /** + * Demographic + * @default null + */ + demographic: string | null; + /** + * Domain + * @default [] + */ + domain: string[]; + /** Lens Fit */ + lens_fit?: { + [key: string]: components["schemas"]["ProposalRevision__LensFit"]; + }; + lifecycle: components["schemas"]["ProposalRevision__Lifecycle"]; + /** + * Limitations + * @default [] + */ + limitations: components["schemas"]["ProposalRevision__Claim"][]; + /** + * Merged From + * @default [] + */ + merged_from: string[]; + /** Model Id */ + model_id: string; + /** Node Id */ + node_id: string; + node_type: components["schemas"]["ProposalRevision__NodeType"]; + /** Project Id */ + project_id: string; + /** + * Reporting Year + * @default null + */ + reporting_year: number | null; + review_state: components["schemas"]["ProposalRevision__ReviewState"]; + /** Run Id */ + run_id: string; + /** + * Sample Size + * @default null + */ + sample_size: number | null; + /** + * Schema Version + * @default 1 + * @constant + */ + schema_version: 1; + /** + * Stale After + * @default null + */ + stale_after: string | null; + /** Summary */ + summary: string; + /** + * Superseded By + * @default [] + */ + superseded_by: string[]; + /** + * Supersedes + * @default [] + */ + supersedes: string[]; + /** + * Tags + * @default [] + */ + tags: string[]; + /** Title */ + title: string; + /** + * Unit + * @default null + */ + unit: string | null; + /** + * Updated + * Format: date-time + */ + updated: string; + /** + * Use For + * @default [] + */ + use_for: string[]; + /** + * Value + * @default null + */ + value: number | null; + }; + /** LensFit */ + ProposalRevision__LensFit: { + /** + * Horizon + * @enum {string} + */ + horizon: "H1" | "H2" | "H3"; + /** + * Key Question + * @default null + */ + key_question: string | null; + /** Reason */ + reason: string; + /** + * Schema Version + * @default 1 + * @constant + */ + schema_version: 1; + /** Score */ + score: number; + }; + /** + * Lifecycle + * @enum {string} + */ + ProposalRevision__Lifecycle: "active" | "superseded"; + /** Locator */ + ProposalRevision__Locator: { + /** Block Id */ + block_id: string; + /** End */ + end: number; + /** + * Kind + * @enum {string} + */ + kind: "text_span" | "pdf_page_span" | "transcript_timestamp_span"; + /** + * Page + * @default null + */ + page: number | null; + /** + * Schema Version + * @default 1 + * @constant + */ + schema_version: 1; + /** Start */ + start: number; + /** + * Time End Ms + * @default null + */ + time_end_ms: number | null; + /** + * Time Start Ms + * @default null + */ + time_start_ms: number | null; + }; + /** + * NodeType + * @enum {string} + */ + ProposalRevision__NodeType: "finding" | "problem" | "technique" | "case" | "entity" | "event" | "concept" | "datapoint" | "synthesis"; + /** ProposalOperation */ + ProposalRevision__ProposalOperation: { + /** @default null */ + edge: components["schemas"]["ProposalRevision__TypedEdge"] | null; + /** @default null */ + node: components["schemas"]["ProposalRevision__KnowledgeNode"] | null; + /** + * Operation + * @enum {string} + */ + operation: "register_source" | "upsert_node" | "add_edge" | "flag_stale"; + /** + * Schema Version + * @default 1 + * @constant + */ + schema_version: 1; + /** @default null */ + source: components["schemas"]["ProposalRevision__SourceRevisionRecord"] | null; + /** + * Target Id + * @default null + */ + target_id: string | null; + }; + /** + * ReliabilityGrade + * @enum {string} + */ + ProposalRevision__ReliabilityGrade: "low" | "medium" | "high"; + /** + * ReviewState + * @enum {string} + */ + ProposalRevision__ReviewState: "draft" | "reviewed"; + /** + * Scope + * @description Project context plus optional project includes; all-project access is deliberate. + */ + ProposalRevision__Scope: { + /** + * All Projects + * @default false + */ + all_projects: boolean; + /** + * Include Projects + * @default null + */ + include_projects: string[] | null; + /** + * Include Shared + * @default true + */ + include_shared: boolean; + /** Projects */ + projects: string[]; + /** + * Schema Version + * @default 1 + * @constant + */ + schema_version: 1; + }; + /** SourceRevisionRecord */ + ProposalRevision__SourceRevisionRecord: { + /** + * Archive Status + * @enum {string} + */ + archive_status: "archived" | "reference-only" | "capture-failed"; + /** + * Artifact Sha256 + * @default null + */ + artifact_sha256: string | null; + /** + * Authors + * @default [] + */ + authors: string[]; + /** Byte Length */ + byte_length: number; + /** + * Capture Method + * @default unspecified + * @enum {string} + */ + capture_method: "unspecified" | "local-file" | "url-fetch" | "pasted-text" | "notebooklm-export" | "transcript"; + /** + * Captured At + * Format: date-time + */ + captured_at: string; + /** + * Final Url + * @default null + */ + final_url: string | null; + /** Ingested For */ + ingested_for: string[]; + /** License */ + license: string; + /** Media Type */ + media_type: string; + /** + * Original Url + * @default null + */ + original_url: string | null; + /** + * Publication Date + * @default null + */ + publication_date: string | null; + /** + * Publisher + * @default null + */ + publisher: string | null; + reliability_grade: components["schemas"]["ProposalRevision__ReliabilityGrade"]; + /** Revision Id */ + revision_id: string; + /** + * Schema Version + * @default 1 + * @constant + */ + schema_version: 1; + /** Source Id */ + source_id: string; + /** + * Source Type + * @enum {string} + */ + source_type: "paper" | "review" | "press" | "vendor" | "internal" | "talk" | "export" | "other"; + /** Title */ + title: string; + }; + /** SupportAssessment */ + ProposalRevision__SupportAssessment: { + /** Assertion Digest */ + assertion_digest: string; + /** Assessment Id */ + assessment_id: string; + /** Assessor Id */ + assessor_id: string; + /** Evidence Digests */ + evidence_digests: string[]; + /** Method */ + method: string; + /** Rationale */ + rationale: string; + /** Run Id */ + run_id: string; + /** + * Schema Version + * @default 1 + * @constant + */ + schema_version: 1; + status: components["schemas"]["ProposalRevision__SupportStatus"]; + }; + /** + * SupportStatus + * @enum {string} + */ + ProposalRevision__SupportStatus: "supported" | "unsupported" | "unavailable"; + /** TypedEdge */ + ProposalRevision__TypedEdge: { + /** + * Conditions + * @default null + */ + conditions: string | null; + /** Edge Id */ + edge_id: string; + /** + * Edge Type + * @enum {string} + */ + edge_type: "solves" | "addresses" | "causes" | "supports" | "contradicts" | "instance_of" | "part_of" | "precedes" | "supersedes" | "compared_with" | "derived_from" | "applies_to"; + /** + * Evidence + * @default [] + */ + evidence: components["schemas"]["ProposalRevision__EvidenceReference"][]; + /** + * Interpretation Rationale + * @default null + */ + interpretation_rationale: string | null; + /** + * Schema Version + * @default 1 + * @constant + */ + schema_version: 1; + source: components["schemas"]["ProposalRevision__EdgeEndpoint"]; + target: components["schemas"]["ProposalRevision__EdgeEndpoint"]; + }; + /** ProposalStatus */ + ProposalStatus: { + /** + * Canonical Revision + * @default null + */ + canonical_revision: string | null; + /** + * Eligible For Auto Accept + * @default null + */ + eligible_for_auto_accept: boolean | null; + /** + * Findings + * @default [] + */ + findings: components["schemas"]["ProposalStatus__Finding"][]; + /** + * Lifecycle + * @enum {string} + */ + lifecycle: "staged" | "evaluated" | "publishing" | "published-unreconciled" | "accepted" | "withdrawn"; + /** Proposal Id */ + proposal_id: string; + /** @default null */ + receipt: components["schemas"]["ProposalStatus__ProposalReceipt"] | null; + /** Revision */ + revision: number; + /** + * Schema Version + * @default 1 + * @constant + */ + schema_version: 1; + /** + * Validation Status + * @default null + */ + validation_status: ("eligible" | "needs-review" | "invalid" | "conflict") | null; + }; + /** Finding */ + ProposalStatus__Finding: { + /** Code */ + code: string; + /** Message */ + message: string; + /** + * Path + * @default null + */ + path: string | null; + /** + * Schema Version + * @default 1 + * @constant + */ + schema_version: 1; + }; + /** ProposalReceipt */ + ProposalStatus__ProposalReceipt: { + /** + * Accepted Node Ids + * @default [] + */ + accepted_node_ids: string[]; + /** + * Commit Id + * @default null + */ + commit_id: string | null; + /** + * Decided At + * @default null + */ + decided_at: string | null; + /** + * Findings + * @default [] + */ + findings: components["schemas"]["ProposalStatus__Finding"][]; + /** + * Outcome + * @enum {string} + */ + outcome: "accepted" | "held" | "rejected" | "validation-failed"; + /** Payload Digest */ + payload_digest: string; + /** Proposal Id */ + proposal_id: string; + /** Revision */ + revision: number; + /** + * Schema Version + * @default 1 + * @constant + */ + schema_version: 1; + state: components["schemas"]["ProposalStatus__ProposalState"]; + }; + /** + * ProposalState + * @enum {string} + */ + ProposalStatus__ProposalState: "staged" | "validating" | "needs-review" | "accepted" | "rejected" | "withdrawn"; + /** ReadEnvelope */ + ReadEnvelope: { + /** + * Api Version + * @default v1 + * @constant + */ + api_version: "v1"; + budget_profile: components["schemas"]["BudgetProfile"]; + /** Canonical Revision */ + canonical_revision: string | null; + /** + * Citations + * @default [] + */ + citations: components["schemas"]["EvidenceReference"][]; + /** Data */ + data: { + [key: string]: unknown; + }[]; + /** Index Revision */ + index_revision: string | null; + /** Next Cursor */ + next_cursor?: string | null; + /** + * Omissions + * @default [] + */ + omissions: components["schemas"]["Finding"][]; + /** Request Id */ + request_id: string; + resolved_scope: components["schemas"]["Scope"]; + /** + * Retrieval Mode + * @enum {string} + */ + retrieval_mode: "keyword" | "hybrid" | "canonical"; + /** + * Schema Version + * @default 1 + * @constant + */ + schema_version: 1; + /** Tokens Used */ + tokens_used: number; + /** + * Warnings + * @default [] + */ + warnings: components["schemas"]["Finding"][]; + }; + /** BudgetProfile */ + ReadEnvelope__BudgetProfile: { + /** Encoding */ + encoding: string; + /** + * Schema Version + * @default 1 + * @constant + */ + schema_version: 1; + /** Tokenizer */ + tokenizer: string; + }; + /** EvidenceReference */ + ReadEnvelope__EvidenceReference: { + /** Assertion Digest */ + assertion_digest: string; + /** Assertion Path */ + assertion_path: string; + /** Extraction Id */ + extraction_id: string; + locator: components["schemas"]["ReadEnvelope__Locator"]; + /** + * Normalization Profile + * @constant + */ + normalization_profile: "unicode-nfc-lf-v1"; + /** Quote */ + quote: string; + /** + * Schema Version + * @default 1 + * @constant + */ + schema_version: 1; + /** Source Revision Id */ + source_revision_id: string; + }; + /** Finding */ + ReadEnvelope__Finding: { + /** Code */ + code: string; + /** Message */ + message: string; + /** + * Path + * @default null + */ + path: string | null; + /** + * Schema Version + * @default 1 + * @constant + */ + schema_version: 1; + }; + /** Locator */ + ReadEnvelope__Locator: { + /** Block Id */ + block_id: string; + /** End */ + end: number; + /** + * Kind + * @enum {string} + */ + kind: "text_span" | "pdf_page_span" | "transcript_timestamp_span"; + /** + * Page + * @default null + */ + page: number | null; + /** + * Schema Version + * @default 1 + * @constant + */ + schema_version: 1; + /** Start */ + start: number; + /** + * Time End Ms + * @default null + */ + time_end_ms: number | null; + /** + * Time Start Ms + * @default null + */ + time_start_ms: number | null; + }; + /** + * Scope + * @description Project context plus optional project includes; all-project access is deliberate. + */ + ReadEnvelope__Scope: { + /** + * All Projects + * @default false + */ + all_projects: boolean; + /** + * Include Projects + * @default null + */ + include_projects: string[] | null; + /** + * Include Shared + * @default true + */ + include_shared: boolean; + /** Projects */ + projects: string[]; + /** + * Schema Version + * @default 1 + * @constant + */ + schema_version: 1; + }; + /** RunCheckpointRequest */ + RunCheckpointRequest: { + /** Budgets */ + budgets?: { + [key: string]: number; + }; + coverage?: components["schemas"]["RunCheckpointRequest__RunCoverage"]; + /** Expected Revision */ + expected_revision: number; + /** + * Output Hashes + * @default [] + */ + output_hashes: string[]; + /** + * Pending Operations + * @default [] + */ + pending_operations: components["schemas"]["RunCheckpointRequest__RunOperationReference"][]; + /** + * Proposals + * @default [] + */ + proposals: components["schemas"]["RunCheckpointRequest__RunProposalReference"][]; + /** Run Id */ + run_id: string; + /** + * Schema Version + * @default 1 + * @constant + */ + schema_version: 1; + /** + * Sources + * @default [] + */ + sources: components["schemas"]["RunCheckpointRequest__RunSourceReference"][]; + /** Stage */ + stage: string; + /** @default active */ + state: components["schemas"]["RunCheckpointRequest__RunState"]; + }; + /** Finding */ + RunCheckpointRequest__Finding: { + /** Code */ + code: string; + /** Message */ + message: string; + /** + * Path + * @default null + */ + path: string | null; + /** + * Schema Version + * @default 1 + * @constant + */ + schema_version: 1; + }; + /** ProposalReceipt */ + RunCheckpointRequest__ProposalReceipt: { + /** + * Accepted Node Ids + * @default [] + */ + accepted_node_ids: string[]; + /** + * Commit Id + * @default null + */ + commit_id: string | null; + /** + * Decided At + * @default null + */ + decided_at: string | null; + /** + * Findings + * @default [] + */ + findings: components["schemas"]["RunCheckpointRequest__Finding"][]; + /** + * Outcome + * @enum {string} + */ + outcome: "accepted" | "held" | "rejected" | "validation-failed"; + /** Payload Digest */ + payload_digest: string; + /** Proposal Id */ + proposal_id: string; + /** Revision */ + revision: number; + /** + * Schema Version + * @default 1 + * @constant + */ + schema_version: 1; + state: components["schemas"]["RunCheckpointRequest__ProposalState"]; + }; + /** + * ProposalState + * @enum {string} + */ + RunCheckpointRequest__ProposalState: "staged" | "validating" | "needs-review" | "accepted" | "rejected" | "withdrawn"; + /** RunCoverage */ + RunCheckpointRequest__RunCoverage: { + /** + * Excluded + * @default [] + */ + excluded: components["schemas"]["RunCheckpointRequest__RunCoverageItem"][]; + /** + * Included + * @default [] + */ + included: string[]; + /** + * Schema Version + * @default 1 + * @constant + */ + schema_version: 1; + /** + * Selected + * @default [] + */ + selected: string[]; + /** + * Skipped + * @default [] + */ + skipped: components["schemas"]["RunCheckpointRequest__RunCoverageItem"][]; + }; + /** RunCoverageItem */ + RunCheckpointRequest__RunCoverageItem: { + /** Item */ + item: string; + /** Reason */ + reason: string; + /** + * Schema Version + * @default 1 + * @constant + */ + schema_version: 1; + }; + /** RunOperationReference */ + RunCheckpointRequest__RunOperationReference: { + /** Input Sha256 */ + input_sha256: string; + /** Operation Id */ + operation_id: string; + /** + * Schema Version + * @default 1 + * @constant + */ + schema_version: 1; + /** + * Status + * @enum {string} + */ + status: "pending" | "uncertain" | "complete" | "failed"; + /** Tool Name */ + tool_name: string; + }; + /** RunProposalReference */ + RunCheckpointRequest__RunProposalReference: { + /** Proposal Id */ + proposal_id: string; + /** @default null */ + receipt: components["schemas"]["RunCheckpointRequest__ProposalReceipt"] | null; + /** Revision */ + revision: number; + /** + * Schema Version + * @default 1 + * @constant + */ + schema_version: 1; + /** + * Status + * @enum {string} + */ + status: "staged" | "evaluated" | "eligible" | "needs-review" | "invalid" | "conflict" | "publishing" | "accepted" | "rejected" | "withdrawn" | "uncertain"; + }; + /** RunSourceReference */ + RunCheckpointRequest__RunSourceReference: { + /** + * Artifact Sha256 + * @default null + */ + artifact_sha256: string | null; + /** + * Extraction Id + * @default null + */ + extraction_id: string | null; + /** + * Schema Version + * @default 1 + * @constant + */ + schema_version: 1; + /** Source Revision Id */ + source_revision_id: string; + /** + * Status + * @enum {string} + */ + status: "selected" | "captured" | "extracted" | "unavailable" | "failed"; + }; + /** + * RunState + * @enum {string} + */ + RunCheckpointRequest__RunState: "active" | "completed" | "blocked"; + /** RunCreateRequest */ + RunCreateRequest: { + /** Lens */ + lens: string; + /** Module Pins */ + module_pins: components["schemas"]["RunCreateRequest__RunModulePin"][]; + /** Objective */ + objective: string; + /** Pack Id */ + pack_id: string; + /** Pack Snapshot Digest */ + pack_snapshot_digest: string; + /** Pack Version */ + pack_version: string; + /** Run Id */ + run_id: string; + /** + * Schema Version + * @default 1 + * @constant + */ + schema_version: 1; + scope: components["schemas"]["RunCreateRequest__Scope"]; + }; + /** RunModulePin */ + RunCreateRequest__RunModulePin: { + /** Implementation Version */ + implementation_version: string; + /** Module Id */ + module_id: string; + /** + * Schema Version + * @default 1 + * @constant + */ + schema_version: 1; + /** Sha256 */ + sha256: string; + }; + /** + * Scope + * @description Project context plus optional project includes; all-project access is deliberate. + */ + RunCreateRequest__Scope: { + /** + * All Projects + * @default false + */ + all_projects: boolean; + /** + * Include Projects + * @default null + */ + include_projects: string[] | null; + /** + * Include Shared + * @default true + */ + include_shared: boolean; + /** Projects */ + projects: string[]; + /** + * Schema Version + * @default 1 + * @constant + */ + schema_version: 1; + }; + /** RunRecord */ + RunRecord: { + /** Budgets */ + budgets?: { + [key: string]: number; + }; + /** + * Checkpoint Digest + * @default null + */ + checkpoint_digest: string | null; + coverage?: components["schemas"]["RunRecord__RunCoverage"]; + /** + * Created At + * Format: date-time + */ + created_at: string; + /** Lens */ + lens: string; + /** Module Pins */ + module_pins: components["schemas"]["RunRecord__RunModulePin"][]; + /** Objective */ + objective: string; + /** + * Output Hashes + * @default [] + */ + output_hashes: string[]; + /** Pack Id */ + pack_id: string; + /** Pack Snapshot Digest */ + pack_snapshot_digest: string; + /** Pack Version */ + pack_version: string; + /** + * Pending Operations + * @default [] + */ + pending_operations: components["schemas"]["RunRecord__RunOperationReference"][]; + /** + * Proposals + * @default [] + */ + proposals: components["schemas"]["RunRecord__RunProposalReference"][]; + /** Revision */ + revision: number; + /** Run Id */ + run_id: string; + /** + * Schema Version + * @default 1 + * @constant + */ + schema_version: 1; + scope: components["schemas"]["RunRecord__Scope"]; + /** + * Sources + * @default [] + */ + sources: components["schemas"]["RunRecord__RunSourceReference"][]; + /** Stage */ + stage: string; + state: components["schemas"]["RunRecord__RunState"]; + /** + * Updated At + * Format: date-time + */ + updated_at: string; + }; + /** Finding */ + RunRecord__Finding: { + /** Code */ + code: string; + /** Message */ + message: string; + /** + * Path + * @default null + */ + path: string | null; + /** + * Schema Version + * @default 1 + * @constant + */ + schema_version: 1; + }; + /** ProposalReceipt */ + RunRecord__ProposalReceipt: { + /** + * Accepted Node Ids + * @default [] + */ + accepted_node_ids: string[]; + /** + * Commit Id + * @default null + */ + commit_id: string | null; + /** + * Decided At + * @default null + */ + decided_at: string | null; + /** + * Findings + * @default [] + */ + findings: components["schemas"]["RunRecord__Finding"][]; + /** + * Outcome + * @enum {string} + */ + outcome: "accepted" | "held" | "rejected" | "validation-failed"; + /** Payload Digest */ + payload_digest: string; + /** Proposal Id */ + proposal_id: string; + /** Revision */ + revision: number; + /** + * Schema Version + * @default 1 + * @constant + */ + schema_version: 1; + state: components["schemas"]["RunRecord__ProposalState"]; + }; + /** + * ProposalState + * @enum {string} + */ + RunRecord__ProposalState: "staged" | "validating" | "needs-review" | "accepted" | "rejected" | "withdrawn"; + /** RunCoverage */ + RunRecord__RunCoverage: { + /** + * Excluded + * @default [] + */ + excluded: components["schemas"]["RunRecord__RunCoverageItem"][]; + /** + * Included + * @default [] + */ + included: string[]; + /** + * Schema Version + * @default 1 + * @constant + */ + schema_version: 1; + /** + * Selected + * @default [] + */ + selected: string[]; + /** + * Skipped + * @default [] + */ + skipped: components["schemas"]["RunRecord__RunCoverageItem"][]; + }; + /** RunCoverageItem */ + RunRecord__RunCoverageItem: { + /** Item */ + item: string; + /** Reason */ + reason: string; + /** + * Schema Version + * @default 1 + * @constant + */ + schema_version: 1; + }; + /** RunModulePin */ + RunRecord__RunModulePin: { + /** Implementation Version */ + implementation_version: string; + /** Module Id */ + module_id: string; + /** + * Schema Version + * @default 1 + * @constant + */ + schema_version: 1; + /** Sha256 */ + sha256: string; + }; + /** RunOperationReference */ + RunRecord__RunOperationReference: { + /** Input Sha256 */ + input_sha256: string; + /** Operation Id */ + operation_id: string; + /** + * Schema Version + * @default 1 + * @constant + */ + schema_version: 1; + /** + * Status + * @enum {string} + */ + status: "pending" | "uncertain" | "complete" | "failed"; + /** Tool Name */ + tool_name: string; + }; + /** RunProposalReference */ + RunRecord__RunProposalReference: { + /** Proposal Id */ + proposal_id: string; + /** @default null */ + receipt: components["schemas"]["RunRecord__ProposalReceipt"] | null; + /** Revision */ + revision: number; + /** + * Schema Version + * @default 1 + * @constant + */ + schema_version: 1; + /** + * Status + * @enum {string} + */ + status: "staged" | "evaluated" | "eligible" | "needs-review" | "invalid" | "conflict" | "publishing" | "accepted" | "rejected" | "withdrawn" | "uncertain"; + }; + /** RunSourceReference */ + RunRecord__RunSourceReference: { + /** + * Artifact Sha256 + * @default null + */ + artifact_sha256: string | null; + /** + * Extraction Id + * @default null + */ + extraction_id: string | null; + /** + * Schema Version + * @default 1 + * @constant + */ + schema_version: 1; + /** Source Revision Id */ + source_revision_id: string; + /** + * Status + * @enum {string} + */ + status: "selected" | "captured" | "extracted" | "unavailable" | "failed"; + }; + /** + * RunState + * @enum {string} + */ + RunRecord__RunState: "active" | "completed" | "blocked"; + /** + * Scope + * @description Project context plus optional project includes; all-project access is deliberate. + */ + RunRecord__Scope: { + /** + * All Projects + * @default false + */ + all_projects: boolean; + /** + * Include Projects + * @default null + */ + include_projects: string[] | null; + /** + * Include Shared + * @default true + */ + include_shared: boolean; + /** Projects */ + projects: string[]; + /** + * Schema Version + * @default 1 + * @constant + */ + schema_version: 1; + }; + /** + * Scope + * @description Project context plus optional project includes; all-project access is deliberate. + */ + Scope: { + /** + * All Projects + * @default false + */ + all_projects: boolean; + /** Include Projects */ + include_projects?: string[] | null; + /** + * Include Shared + * @default true + */ + include_shared: boolean; + /** Projects */ + projects: string[]; + /** + * Schema Version + * @default 1 + * @constant + */ + schema_version: 1; + }; + /** SearchRequest */ + SearchRequest: { + /** + * As Of From + * @default null + */ + as_of_from: string | null; + /** + * As Of To + * @default null + */ + as_of_to: string | null; + /** + * Audience + * @default [] + */ + audience: string[]; + /** Budget Tokens */ + budget_tokens: number; + /** + * Confidences + * @default [] + */ + confidences: components["schemas"]["SearchRequest__Confidence"][]; + /** + * Cursor + * @default null + */ + cursor: string | null; + /** + * Domain + * @default [] + */ + domain: string[]; + /** + * Limit + * @default 20 + */ + limit: number; + /** + * Node Types + * @default [] + */ + node_types: components["schemas"]["SearchRequest__NodeType"][]; + /** + * Query + * @default + */ + query: string; + /** + * Schema Version + * @default 1 + * @constant + */ + schema_version: 1; + scope: components["schemas"]["SearchRequest__Scope"]; + /** + * Stale Only + * @default false + */ + stale_only: boolean; + /** + * Tags + * @default [] + */ + tags: string[]; + /** + * Use For + * @default [] + */ + use_for: string[]; + }; + /** SearchRequestBody */ + SearchRequestBody: { + /** As Of From */ + as_of_from?: string | null; + /** As Of To */ + as_of_to?: string | null; + /** Audience */ + audience?: string[]; + /** Budget Tokens */ + budget_tokens: number; + /** Confidences */ + confidences?: components["schemas"]["Confidence"][]; + /** Cursor */ + cursor?: string | null; + /** Domain */ + domain?: string[]; + /** + * Limit + * @default 20 + */ + limit: number; + /** Node Types */ + node_types?: string[]; + /** + * Query + * @default + */ + query: string; + scope: components["schemas"]["SearchScopeBody"]; + /** + * Stale Only + * @default false + */ + stale_only: boolean; + /** Tags */ + tags?: string[]; + /** Use For */ + use_for?: string[]; + }; + /** + * Confidence + * @enum {string} + */ + SearchRequest__Confidence: "low" | "medium" | "high"; + /** + * NodeType + * @enum {string} + */ + SearchRequest__NodeType: "finding" | "problem" | "technique" | "case" | "entity" | "event" | "concept" | "datapoint" | "synthesis"; + /** + * Scope + * @description Project context plus optional project includes; all-project access is deliberate. + */ + SearchRequest__Scope: { + /** + * All Projects + * @default false + */ + all_projects: boolean; + /** + * Include Projects + * @default null + */ + include_projects: string[] | null; + /** + * Include Shared + * @default true + */ + include_shared: boolean; + /** Projects */ + projects: string[]; + /** + * Schema Version + * @default 1 + * @constant + */ + schema_version: 1; + }; + /** SearchScopeBody */ + SearchScopeBody: { + /** + * All Projects + * @default false + */ + all_projects: boolean; + /** Include Projects */ + include_projects?: string[] | null; + /** + * Include Shared + * @default true + */ + include_shared: boolean; + /** Projects */ + projects: string[]; + }; + /** ServiceDiagnostics */ + ServiceDiagnostics: { + /** + * Api Version + * @default v1 + * @constant + */ + api_version: "v1"; + /** + * Authentication + * @enum {string} + */ + authentication: "bearer" | "browser_session"; + /** + * Authorized Operations + * @default [] + */ + authorized_operations: string[]; + /** + * Authorized Projects + * @default [] + */ + authorized_projects: string[]; + /** Canonical Revision */ + canonical_revision: string; + /** + * Capabilities + * @default [] + */ + capabilities: string[]; + /** Catalog Sha256 */ + catalog_sha256: string; + /** + * Connection Status + * @default authenticated + * @constant + */ + connection_status: "authenticated"; + /** Index Revision */ + index_revision?: string | null; + /** Index Schema Version */ + index_schema_version?: number | null; + /** + * Index State + * @enum {string} + */ + index_state: "current" | "lagging" | "not_built" | "not_configured" | "unavailable"; + /** Max Upload Bytes */ + max_upload_bytes: number; + /** + * Roles + * @default [] + */ + roles: string[]; + /** + * Schema Version + * @default 1 + * @constant + */ + schema_version: 1; + selected_scope?: components["schemas"]["Scope"] | null; + /** Service Version */ + service_version: string; + /** + * Supported Media Types + * @default [] + */ + supported_media_types: string[]; + /** + * Transport + * @enum {string} + */ + transport: "http" | "mcp"; + /** Upload Chunk Bytes */ + upload_chunk_bytes: number; + /** + * Workflow Status + * @default not_verified + * @constant + */ + workflow_status: "not_verified"; + }; + /** + * Scope + * @description Project context plus optional project includes; all-project access is deliberate. + */ + ServiceDiagnostics__Scope: { + /** + * All Projects + * @default false + */ + all_projects: boolean; + /** + * Include Projects + * @default null + */ + include_projects: string[] | null; + /** + * Include Shared + * @default true + */ + include_shared: boolean; + /** Projects */ + projects: string[]; + /** + * Schema Version + * @default 1 + * @constant + */ + schema_version: 1; + }; + /** SourceListRequestBody */ + SourceListRequestBody: { + /** Budget Tokens */ + budget_tokens: number; + /** Cursor */ + cursor?: string | null; + /** + * Limit + * @default 20 + */ + limit: number; + scope: components["schemas"]["SearchScopeBody"]; + }; + /** SourceRevisionRecord */ + SourceRevisionRecord: { + /** + * Archive Status + * @enum {string} + */ + archive_status: "archived" | "reference-only" | "capture-failed"; + /** + * Artifact Sha256 + * @default null + */ + artifact_sha256: string | null; + /** + * Authors + * @default [] + */ + authors: string[]; + /** Byte Length */ + byte_length: number; + /** + * Capture Method + * @default unspecified + * @enum {string} + */ + capture_method: "unspecified" | "local-file" | "url-fetch" | "pasted-text" | "notebooklm-export" | "transcript"; + /** + * Captured At + * Format: date-time + */ + captured_at: string; + /** + * Final Url + * @default null + */ + final_url: string | null; + /** Ingested For */ + ingested_for: string[]; + /** License */ + license: string; + /** Media Type */ + media_type: string; + /** + * Original Url + * @default null + */ + original_url: string | null; + /** + * Publication Date + * @default null + */ + publication_date: string | null; + /** + * Publisher + * @default null + */ + publisher: string | null; + reliability_grade: components["schemas"]["SourceRevisionRecord__ReliabilityGrade"]; + /** Revision Id */ + revision_id: string; + /** + * Schema Version + * @default 1 + * @constant + */ + schema_version: 1; + /** Source Id */ + source_id: string; + /** + * Source Type + * @enum {string} + */ + source_type: "paper" | "review" | "press" | "vendor" | "internal" | "talk" | "export" | "other"; + /** Title */ + title: string; + }; + /** + * ReliabilityGrade + * @enum {string} + */ + SourceRevisionRecord__ReliabilityGrade: "low" | "medium" | "high"; + /** TypedEdge */ + TypedEdge: { + /** + * Conditions + * @default null + */ + conditions: string | null; + /** Edge Id */ + edge_id: string; + /** + * Edge Type + * @enum {string} + */ + edge_type: "solves" | "addresses" | "causes" | "supports" | "contradicts" | "instance_of" | "part_of" | "precedes" | "supersedes" | "compared_with" | "derived_from" | "applies_to"; + /** + * Evidence + * @default [] + */ + evidence: components["schemas"]["TypedEdge__EvidenceReference"][]; + /** + * Interpretation Rationale + * @default null + */ + interpretation_rationale: string | null; + /** + * Schema Version + * @default 1 + * @constant + */ + schema_version: 1; + source: components["schemas"]["TypedEdge__EdgeEndpoint"]; + target: components["schemas"]["TypedEdge__EdgeEndpoint"]; + }; + /** EdgeEndpoint */ + TypedEdge__EdgeEndpoint: { + /** Id */ + id: string; + /** + * Kind + * @enum {string} + */ + kind: "node" | "source" | "project"; + /** + * Schema Version + * @default 1 + * @constant + */ + schema_version: 1; + }; + /** EvidenceReference */ + TypedEdge__EvidenceReference: { + /** Assertion Digest */ + assertion_digest: string; + /** Assertion Path */ + assertion_path: string; + /** Extraction Id */ + extraction_id: string; + locator: components["schemas"]["TypedEdge__Locator"]; + /** + * Normalization Profile + * @constant + */ + normalization_profile: "unicode-nfc-lf-v1"; + /** Quote */ + quote: string; + /** + * Schema Version + * @default 1 + * @constant + */ + schema_version: 1; + /** Source Revision Id */ + source_revision_id: string; + }; + /** Locator */ + TypedEdge__Locator: { + /** Block Id */ + block_id: string; + /** End */ + end: number; + /** + * Kind + * @enum {string} + */ + kind: "text_span" | "pdf_page_span" | "transcript_timestamp_span"; + /** + * Page + * @default null + */ + page: number | null; + /** + * Schema Version + * @default 1 + * @constant + */ + schema_version: 1; + /** Start */ + start: number; + /** + * Time End Ms + * @default null + */ + time_end_ms: number | null; + /** + * Time Start Ms + * @default null + */ + time_start_ms: number | null; + }; + /** ValidationError */ + ValidationError: { + /** Context */ + ctx?: Record; + /** Input */ + input?: unknown; + /** Location */ + loc: (string | number)[]; + /** Message */ + msg: string; + /** Error Type */ + type: string; + }; + }; + responses: never; + parameters: never; + requestBodies: never; + headers: never; + pathItems: never; +} +export type $defs = Record; +export interface operations { + owner_session_create: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["OwnerSessionRequestBody"]; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + [key: string]: string; + }; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + health: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + [key: string]: string; + }; + }; + }; + }; + }; + admin_list_credentials: { + parameters: { + query?: { + limit?: number; + offset?: number; + }; + header?: { + authorization?: string | null; + "X-GEWU-CSRF"?: string | null; + }; + path?: never; + cookie?: { + gewu_session?: string | null; + }; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["CredentialSummary"][]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + admin_create_credential: { + parameters: { + query?: never; + header?: { + authorization?: string | null; + "X-GEWU-CSRF"?: string | null; + }; + path?: never; + cookie?: { + gewu_session?: string | null; + }; + }; + requestBody: { + content: { + "application/json": components["schemas"]["CredentialCreateBody"]; + }; + }; + responses: { + /** @description Successful Response */ + 201: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["IssuedCredential"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + admin_revoke_credential: { + parameters: { + query?: never; + header?: { + authorization?: string | null; + "X-GEWU-CSRF"?: string | null; + }; + path: { + credential_id: string; + }; + cookie?: { + gewu_session?: string | null; + }; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + [key: string]: string; + }; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + admin_update_credential: { + parameters: { + query?: never; + header?: { + authorization?: string | null; + "X-GEWU-CSRF"?: string | null; + }; + path: { + credential_id: string; + }; + cookie?: { + gewu_session?: string | null; + }; + }; + requestBody: { + content: { + "application/json": components["schemas"]["CredentialUpdateBody"]; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["CredentialSummary"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + admin_list_events: { + parameters: { + query?: { + limit?: number; + }; + header?: { + authorization?: string | null; + "X-GEWU-CSRF"?: string | null; + }; + path?: never; + cookie?: { + gewu_session?: string | null; + }; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["AdminEvent"][]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + admin_list_projects: { + parameters: { + query?: never; + header?: { + authorization?: string | null; + }; + path?: never; + cookie?: { + gewu_session?: string | null; + }; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ProjectRecord"][]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + project_create: { + parameters: { + query?: never; + header?: { + authorization?: string | null; + "X-GEWU-CSRF"?: string | null; + }; + path?: never; + cookie?: { + gewu_session?: string | null; + }; + }; + requestBody: { + content: { + "application/json": components["schemas"]["ProjectCreateBody"]; + }; + }; + responses: { + /** @description Successful Response */ + 201: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ProjectRecord"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + project_update_content: { + parameters: { + query?: never; + header?: { + authorization?: string | null; + "X-GEWU-CSRF"?: string | null; + }; + path: { + project_id: string; + }; + cookie?: { + gewu_session?: string | null; + }; + }; + requestBody: { + content: { + "application/json": components["schemas"]["ProjectContentUpdateBody"]; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ProjectRecord"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + project_archive: { + parameters: { + query?: never; + header?: { + authorization?: string | null; + "X-GEWU-CSRF"?: string | null; + }; + path: { + project_id: string; + }; + cookie?: { + gewu_session?: string | null; + }; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ProjectRecord"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + admin_list_accounts: { + parameters: { + query?: { + limit?: number; + offset?: number; + }; + header?: { + authorization?: string | null; + "X-GEWU-CSRF"?: string | null; + }; + path?: never; + cookie?: { + gewu_session?: string | null; + }; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["AccountSummary"][]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + admin_create_account: { + parameters: { + query?: never; + header?: { + authorization?: string | null; + "X-GEWU-CSRF"?: string | null; + }; + path?: never; + cookie?: { + gewu_session?: string | null; + }; + }; + requestBody: { + content: { + "application/json": components["schemas"]["AccountCreateBody"]; + }; + }; + responses: { + /** @description Successful Response */ + 201: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["AccountSummary"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + admin_disable_account: { + parameters: { + query?: never; + header?: { + authorization?: string | null; + "X-GEWU-CSRF"?: string | null; + }; + path: { + username: string; + }; + cookie?: { + gewu_session?: string | null; + }; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + [key: string]: string; + }; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + admin_update_account: { + parameters: { + query?: never; + header?: { + authorization?: string | null; + "X-GEWU-CSRF"?: string | null; + }; + path: { + username: string; + }; + cookie?: { + gewu_session?: string | null; + }; + }; + requestBody: { + content: { + "application/json": components["schemas"]["AccountUpdateBody"]; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["AccountSummary"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + diagnostics: { + parameters: { + query?: { + projects?: string[] | null; + include_shared?: boolean; + }; + header?: { + authorization?: string | null; + }; + path?: never; + cookie?: { + gewu_session?: string | null; + }; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ServiceDiagnostics"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + account_current: { + parameters: { + query?: never; + header?: { + authorization?: string | null; + "X-GEWU-CSRF"?: string | null; + }; + path?: never; + cookie?: { + gewu_session?: string | null; + }; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["CurrentUser"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + account_list_agent_credentials: { + parameters: { + query?: { + limit?: number; + offset?: number; + }; + header?: { + authorization?: string | null; + "X-GEWU-CSRF"?: string | null; + }; + path?: never; + cookie?: { + gewu_session?: string | null; + }; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["CredentialSummary"][]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + account_create_agent_credential: { + parameters: { + query?: never; + header?: { + authorization?: string | null; + "X-GEWU-CSRF"?: string | null; + }; + path?: never; + cookie?: { + gewu_session?: string | null; + }; + }; + requestBody: { + content: { + "application/json": components["schemas"]["AgentCreateBody"]; + }; + }; + responses: { + /** @description Successful Response */ + 201: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["IssuedCredential"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + account_revoke_agent_credential: { + parameters: { + query?: never; + header?: { + authorization?: string | null; + "X-GEWU-CSRF"?: string | null; + }; + path: { + credential_id: string; + }; + cookie?: { + gewu_session?: string | null; + }; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + [key: string]: string; + }; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + account_change_password: { + parameters: { + query?: never; + header?: { + authorization?: string | null; + "X-GEWU-CSRF"?: string | null; + }; + path?: never; + cookie?: { + gewu_session?: string | null; + }; + }; + requestBody: { + content: { + "application/json": components["schemas"]["PasswordChangeBody"]; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + [key: string]: string; + }; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + neighbors: { + parameters: { + query?: never; + header?: { + authorization?: string | null; + "X-GEWU-CSRF"?: string | null; + }; + path?: never; + cookie?: { + gewu_session?: string | null; + }; + }; + requestBody: { + content: { + "application/json": components["schemas"]["NeighborRequestBody"]; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ReadEnvelope"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + list_projects: { + parameters: { + query?: never; + header?: { + authorization?: string | null; + }; + path?: never; + cookie?: { + gewu_session?: string | null; + }; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": string[]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + get_node: { + parameters: { + query?: { + revision?: string | null; + }; + header?: { + authorization?: string | null; + }; + path: { + project_id: string; + node_id: string; + }; + cookie?: { + gewu_session?: string | null; + }; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ReadEnvelope"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + node_history: { + parameters: { + query?: { + limit?: number; + }; + header?: { + authorization?: string | null; + }; + path: { + project_id: string; + node_id: string; + }; + cookie?: { + gewu_session?: string | null; + }; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ReadEnvelope"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + get_source: { + parameters: { + query?: never; + header?: { + authorization?: string | null; + }; + path: { + project_id: string; + revision_id: string; + }; + cookie?: { + gewu_session?: string | null; + }; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ReadEnvelope"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + get_source_artifact: { + parameters: { + query?: never; + header?: { + authorization?: string | null; + }; + path: { + project_id: string; + revision_id: string; + }; + cookie?: { + gewu_session?: string | null; + }; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": unknown; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + get_source_location: { + parameters: { + query: { + offset: number; + length: number; + }; + header?: { + authorization?: string | null; + }; + path: { + project_id: string; + revision_id: string; + extraction_id: string; + block_id: string; + }; + cookie?: { + gewu_session?: string | null; + }; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + [key: string]: unknown; + }; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + search: { + parameters: { + query?: never; + header?: { + authorization?: string | null; + "X-GEWU-CSRF"?: string | null; + }; + path?: never; + cookie?: { + gewu_session?: string | null; + }; + }; + requestBody: { + content: { + "application/json": components["schemas"]["SearchRequestBody"]; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ReadEnvelope"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + browser_session_restore: { + parameters: { + query?: never; + header?: { + "X-GEWU-Session"?: string | null; + }; + path?: never; + cookie?: { + gewu_session?: string | null; + }; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + [key: string]: string | boolean; + }; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + browser_session_create: { + parameters: { + query?: never; + header?: { + authorization?: string | null; + }; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + [key: string]: string; + }; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + browser_session_revoke: { + parameters: { + query?: never; + header?: { + "X-GEWU-CSRF"?: string | null; + }; + path?: never; + cookie?: { + gewu_session?: string | null; + }; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + [key: string]: string; + }; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + list_sources: { + parameters: { + query?: never; + header?: { + authorization?: string | null; + "X-GEWU-CSRF"?: string | null; + }; + path?: never; + cookie?: { + gewu_session?: string | null; + }; + }; + requestBody: { + content: { + "application/json": components["schemas"]["SourceListRequestBody"]; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ReadEnvelope"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + account_login: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["LoginBody"]; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + [key: string]: string; + }; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; +} diff --git a/web/src/main.tsx b/web/src/main.tsx new file mode 100644 index 0000000..6f3d071 --- /dev/null +++ b/web/src/main.tsx @@ -0,0 +1,21 @@ +import React from 'react'; +import ReactDOM from 'react-dom/client'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { BrowserRouter, Route, Routes } from 'react-router'; +import App from './shell/App'; +import Admin from './shell/Admin'; +import './styles/base.css'; + +const queryClient = new QueryClient({ + defaultOptions: { queries: { refetchOnWindowFocus: false, retry: 1 } }, +}); + +ReactDOM.createRoot(document.getElementById('root')!).render( + + + + } />} />} /> + + + , +); diff --git a/web/src/shell/AccountControls.tsx b/web/src/shell/AccountControls.tsx new file mode 100644 index 0000000..ff7a14d --- /dev/null +++ b/web/src/shell/AccountControls.tsx @@ -0,0 +1,115 @@ +import { memo, useEffect, useRef, useState, type FormEvent } from 'react'; +import { AdminDialog, AdminList, AdminListItem } from './AdminPrimitives'; +import { accountRequest } from './account-api'; + +type User = { username: string; role: string; project_ids: string[]; active: boolean }; +type Project = { project_id: string; title: string }; + +function AccountsSectionComponent({ csrf, projects, onMessage, onError }: { + csrf: string; projects: Project[]; onMessage: (message: string) => void; onError: (message: string) => void; +}) { + const [users, setUsers] = useState([]); + const [username, setUsername] = useState(''); + const [password, setPassword] = useState(''); + const [role, setRole] = useState('editor'); + const [grants, setGrants] = useState([]); + const [offset, setOffset] = useState(0); + const [busy, setBusy] = useState(false); + const [disable, setDisable] = useState(null); + const [addOpen, setAddOpen] = useState(false); + const [editing, setEditing] = useState(null); + const [editRole, setEditRole] = useState('editor'); + const [editGrants, setEditGrants] = useState([]); + const addButtonRef = useRef(null); + + async function load(page = offset) { + const result = await accountRequest(`/v1/admin/users?limit=50&offset=${page}`, csrf); + setUsers(result); setOffset(page); + } + useEffect(() => { + let active = true; + void accountRequest('/v1/admin/users?limit=50&offset=0', csrf) + .then(result => { if (active) setUsers(result); }) + .catch(error => { if (active) onError(error instanceof Error ? error.message : 'Could not load users.'); }); + return () => { active = false; }; + }, [csrf, onError]); + + async function perform(action: () => Promise) { + setBusy(true); onError(''); + try { await action(); } catch (error) { onError(error instanceof Error ? error.message : 'Account operation failed.'); } + finally { setBusy(false); } + } + async function create(event: FormEvent) { + event.preventDefault(); + await perform(async () => { + await accountRequest('/v1/admin/users', csrf, { method: 'POST', body: JSON.stringify({ username, password, role, project_ids: grants }) }); + setPassword(''); setUsername(''); setGrants([]); setAddOpen(false); + onMessage(`Account created for ${username}. They can now sign in and manage their agents.`); await load(0); + }); + setPassword(''); + } + function startEdit(user: User) { + setEditing(user.username); setEditRole(user.role); setEditGrants([...user.project_ids]); setDisable(null); + } + async function saveEdit(event: FormEvent) { + event.preventDefault(); if (!editing) return; + await perform(async () => { + await accountRequest(`/v1/admin/users/${encodeURIComponent(editing)}`, csrf, { method: 'PATCH', body: JSON.stringify({ role: editRole, project_ids: editRole === 'admin' ? [] : editGrants }) }); + onMessage(`Updated ${editing}.`); setEditing(null); await load(); + }); + } + return
+

Users

Manage account roles and project access.

+ +
+ {users.map(user => {user.active && <>}{!user.active && Disabled}}> + {editing === user.username &&
+ + {editRole !== 'admin' &&
Project access{projects.map(project => )}
} +
+
} + {disable === user.username &&

Disable this user and all their agent keys?

} +
)}
+ {!users.length &&

No user accounts yet.

} +
Page {offset / 50 + 1}
+ setAddOpen(false)} returnFocusRef={addButtonRef}> +

Users need a password with at least 12 characters. They can change it after signing in.

+
+ + + +
Project access{role === 'admin' ?

All projects

: projects.map(project => )}
+ +
+
+
; +} + +export const AccountsSection = memo(AccountsSectionComponent); + +export function PasswordSection({ csrf, onChanged, onError }: { + csrf: string; onChanged: () => void; onError: (message: string) => void; +}) { + const [current, setCurrent] = useState(''); + const [next, setNext] = useState(''); + const [confirm, setConfirm] = useState(''); + const [busy, setBusy] = useState(false); + async function change(event: FormEvent) { + event.preventDefault(); onError(''); + if (next !== confirm) { onError('New passwords do not match.'); return; } + setBusy(true); + try { + await accountRequest('/v1/me/password', csrf, { method: 'POST', body: JSON.stringify({ current_password: current, new_password: next }) }); + setCurrent(''); setNext(''); setConfirm(''); onChanged(); + } catch (error) { onError(error instanceof Error ? error.message : 'Password change failed.'); } + finally { setCurrent(''); setBusy(false); } + } + return

Change your password

Changing your password signs out all your browser sessions. Agent keys remain valid.

+ + + + +
; +} diff --git a/web/src/shell/AccountLogin.tsx b/web/src/shell/AccountLogin.tsx new file mode 100644 index 0000000..d4e137b --- /dev/null +++ b/web/src/shell/AccountLogin.tsx @@ -0,0 +1,66 @@ +import { useEffect, useRef, useState, type FormEvent } from 'react'; + +export default function AccountLogin({ onLogin, busy, credentialLabel, onError }: { + onLogin: (session: { csrf_token: string }, passwordAccount: boolean, restored?: boolean) => Promise; + busy: boolean; + credentialLabel: string; + onError: (message: string) => void; +}) { + const [checking, setChecking] = useState(true); + const callbacks = useRef({ onLogin, onError }); + useEffect(() => { callbacks.current = { onLogin, onError }; }, [onLogin, onError]); + useEffect(() => { + const controller = new AbortController(); + void (async () => { + try { + const response = await fetch('/v1/session', { + method: 'GET', credentials: 'same-origin', cache: 'no-store', + headers: { 'X-GEWU-Session': 'restore' }, signal: controller.signal, + }); + if (response.status === 401) return; + if (!response.ok) throw new Error('Could not restore your session. Try signing in again.'); + const session = await response.json() as { csrf_token: string; password_account: boolean }; + if (!controller.signal.aborted) await callbacks.current.onLogin(session, session.password_account, true); + } catch (failure) { + if (!controller.signal.aborted) callbacks.current.onError(failure instanceof Error ? failure.message : 'Session restoration failed.'); + } finally { if (!controller.signal.aborted) setChecking(false); } + })(); + return () => controller.abort(); + }, []); + const [legacy, setLegacy] = useState(false); + const [username, setUsername] = useState(''); + const [password, setPassword] = useState(''); + const [token, setToken] = useState(''); + const [working, setWorking] = useState(false); + + async function submit(event: FormEvent) { + event.preventDefault(); setWorking(true); onError(''); + try { + const response = await fetch(legacy ? '/v1/session' : '/v1/users/session', { + method: 'POST', credentials: 'same-origin', cache: 'no-store', + headers: legacy ? { Authorization: `Bearer ${token.trim()}` } : { 'Content-Type': 'application/json' }, + ...(legacy ? {} : { body: JSON.stringify({ username, password }) }), + }); + setPassword(''); setToken(''); + if (!response.ok) { + const body = await response.json().catch(() => ({})); + throw new Error(typeof body.detail === 'string' ? body.detail : 'Sign-in failed.'); + } + await onLogin(await response.json() as { csrf_token: string }, !legacy); + } catch (failure) { onError(failure instanceof Error ? failure.message : 'Sign-in failed.'); } + finally { setWorking(false); } + } + + if (checking) return

Restoring your session…

; + + return
+
+ {legacy ? <> setToken(e.target.value)} required /> : <> + setUsername(e.target.value)} required maxLength={64} pattern="[a-z0-9][a-z0-9._\-]*" /> + setPassword(e.target.value)} required maxLength={1024} /> + } + +
+ +
; +} diff --git a/web/src/shell/Accounts.test.tsx b/web/src/shell/Accounts.test.tsx new file mode 100644 index 0000000..c0d8e74 --- /dev/null +++ b/web/src/shell/Accounts.test.tsx @@ -0,0 +1,69 @@ +import { cleanup, render, screen, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { afterEach, expect, it, vi } from 'vitest'; +import Admin from './Admin'; + +afterEach(() => { cleanup(); vi.unstubAllGlobals(); }); + +it('signs in with a password, creates an owned agent key and changes the password', async () => { + const fetchMock = vi.fn(async (path: string, init?: RequestInit) => { + if (path === '/v1/session' && init?.method === 'GET') return { ok: false, status: 401, json: async () => ({}) }; + let result: unknown = []; + if (path === '/v1/users/session') result = { csrf_token: 'user-csrf' }; + if (path === '/v1/projects') result = ['pilot']; + if (path === '/v1/me/agent-credentials' && init?.method === 'POST') result = { credential_id: 'agent:codex:fixture', token: 'agent-fixture-secret' }; + if (path === '/v1/me/password') result = { status: 'password_changed' }; + return { ok: true, json: async () => result }; + }); + vi.stubGlobal('fetch', fetchMock); + const user = userEvent.setup(); + render(); + await user.type(await screen.findByLabelText('Username'), 'alice'); + await user.type(screen.getByLabelText('Password'), 'initial-password-fixture'); + await user.click(screen.getByRole('button', { name: 'Sign in' })); + await screen.findByRole('heading', { name: 'Add an agent' }); + expect(fetchMock).toHaveBeenCalledWith('/v1/users/session', expect.objectContaining({ + method: 'POST', credentials: 'same-origin', body: JSON.stringify({ username: 'alice', password: 'initial-password-fixture' }), + })); + expect(screen.queryByRole('heading', { name: 'User accounts' })).toBeNull(); + await user.type(screen.getByLabelText('Agent name'), 'codex-laptop'); + await user.click(screen.getByLabelText('pilot')); + await user.click(screen.getByRole('button', { name: 'Create agent key' })); + expect(await screen.findByLabelText('New bearer credential')).toHaveValue('agent-fixture-secret'); + expect(fetchMock).toHaveBeenCalledWith('/v1/me/agent-credentials', expect.objectContaining({ + headers: expect.objectContaining({ 'X-GEWU-CSRF': 'user-csrf' }), + body: JSON.stringify({ name: 'codex-laptop', project_ids: ['pilot'] }), + })); + await user.type(screen.getByLabelText('Current password'), 'initial-password-fixture'); + await user.type(screen.getByLabelText('New password'), 'changed-password-fixture'); + await user.type(screen.getByLabelText('Confirm new password'), 'changed-password-fixture'); + await user.click(screen.getByRole('button', { name: 'Change password' })); + expect(await screen.findByLabelText('Username')).toBeVisible(); + expect(screen.queryByLabelText('New bearer credential')).toBeNull(); + expect(screen.getByRole('status')).toHaveTextContent('Password changed.'); +}); + +it('lets the existing administrator create the first password account', async () => { + const fetchMock = vi.fn(async (path: string, init?: RequestInit) => { + if (path === '/v1/session' && init?.method === 'GET') return { ok: false, status: 401, json: async () => ({}) }; + const result = path === '/v1/session' ? { csrf_token: 'operator-csrf' } : []; + return { ok: true, json: async () => result }; + }); + vi.stubGlobal('fetch', fetchMock); + const user = userEvent.setup(); + render(); + await user.click(await screen.findByRole('button', { name: 'Use an existing credential' })); + await user.type(screen.getByLabelText('GEWU administrator credential'), 'operator-fixture'); + await user.click(screen.getByRole('button', { name: 'Connect' })); + await user.click(await screen.findByRole('tab', { name: 'Users' })); + const accounts = await screen.findByRole('region', { name: 'User accounts' }); + await user.type(within(accounts).getByLabelText('New username'), 'vasily'); + await user.type(within(accounts).getByLabelText('Initial password'), 'chosen-password-fixture'); + await user.selectOptions(within(accounts).getByLabelText('Account role'), 'admin'); + await user.click(within(accounts).getByRole('button', { name: 'Create user' })); + await screen.findByText(/Account created for vasily/); + expect(fetchMock).toHaveBeenCalledWith('/v1/admin/users', expect.objectContaining({ + body: JSON.stringify({ username: 'vasily', password: 'chosen-password-fixture', role: 'admin', project_ids: [] }), + })); + expect(within(accounts).getByLabelText('Initial password')).toHaveValue(''); +}); diff --git a/web/src/shell/Admin.test.tsx b/web/src/shell/Admin.test.tsx new file mode 100644 index 0000000..5a4c5d8 --- /dev/null +++ b/web/src/shell/Admin.test.tsx @@ -0,0 +1,134 @@ +import { cleanup, render, screen, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { afterEach, expect, it, vi } from 'vitest'; +import Admin from './Admin'; + +const key = { credential_id: 'reader-key', principal_id: 'reader', roles: ['viewer'], project_ids: ['pilot'], revoked: false }; + +afterEach(() => { cleanup(); vi.unstubAllGlobals(); }); + +it('creates a scoped key, dismisses its one-time secret, and confirms revocation with CSRF', async () => { + const fetchMock = vi.fn(async (path: string, init?: RequestInit) => { + if (path === '/v1/session' && init?.method === 'GET') return { ok: false, status: 401, json: async () => ({}) }; + let result: unknown = {}; + if (path === '/v1/session') result = { csrf_token: 'csrf-fixture' }; + if (path.startsWith('/v1/admin/credentials?')) result = [key]; + if (path.startsWith('/v1/admin/users?')) result = []; + if (path === '/v1/admin/projects') result = [{ project_id: 'pilot', title: 'Pilot research' }]; + if (path === '/v1/admin/events?limit=50') result = []; + if (path === '/v1/admin/credentials' && init?.method === 'POST') result = { credential_id: 'new-key', token: 'one-time-fixture-secret' }; + return { ok: true, json: async () => result }; + }); + vi.stubGlobal('fetch', fetchMock); + const user = userEvent.setup(); + render(); + await user.click(await screen.findByRole('button', { name: 'Use an existing credential' })); + await user.type(screen.getByLabelText('GEWU administrator credential'), 'admin-fixture'); + await user.click(screen.getByRole('button', { name: 'Connect' })); + await user.click(await screen.findByRole('tab', { name: 'Credentials' })); + await screen.findByRole('heading', { name: 'Credentials' }); + await user.type(screen.getByLabelText('Credential ID'), 'new-key'); + await user.type(screen.getByLabelText('Principal ID'), 'researcher'); + await user.click(within(screen.getByRole('region', { name: 'Create a credential' })).getByLabelText('Pilot research')); + await user.click(screen.getByRole('button', { name: 'Create credential' })); + expect(await screen.findByLabelText('New bearer credential')).toHaveValue('one-time-fixture-secret'); + expect(fetchMock).toHaveBeenCalledWith('/v1/admin/credentials', expect.objectContaining({ + method: 'POST', headers: expect.objectContaining({ 'X-GEWU-CSRF': 'csrf-fixture' }), + body: JSON.stringify({ credential_id: 'new-key', principal_id: 'researcher', role: 'viewer', project_ids: ['pilot'] }), + })); + await user.click(screen.getByRole('button', { name: 'I have saved it — dismiss' })); + expect(screen.queryByLabelText('New bearer credential')).toBeNull(); + await user.click(screen.getByRole('button', { name: 'Revoke reader-key' })); + expect(fetchMock.mock.calls.some(([path]) => path.endsWith('/reader-key'))).toBe(false); + const row = screen.getByRole('row', { name: /reader-key/ }); + await user.click(within(row).getByRole('button', { name: 'Confirm revoke' })); + await screen.findByText('Revoked reader-key.'); + expect(fetchMock).toHaveBeenCalledWith('/v1/admin/credentials/reader-key', expect.objectContaining({ + method: 'DELETE', headers: expect.objectContaining({ 'X-GEWU-CSRF': 'csrf-fixture' }), + })); +}); + +it('reports rejected connections without exposing management controls', async () => { + vi.stubGlobal('fetch', vi.fn(async () => ({ ok: false, status: 403, json: async () => ({ detail: 'Administrator role is required' }) }))); + const user = userEvent.setup(); + render(); + await user.click(await screen.findByRole('button', { name: 'Use an existing credential' })); + await user.type(screen.getByLabelText('GEWU administrator credential'), 'viewer-fixture'); + await user.click(screen.getByRole('button', { name: 'Connect' })); + expect(await screen.findByRole('alert')).toHaveTextContent('Administrator role is required'); + expect(screen.queryByRole('heading', { name: 'Create a credential' })).toBeNull(); +}); + +it('clears private panel state and permits reconnection after session expiry', async () => { + let expired = false; + vi.stubGlobal('fetch', vi.fn(async (path: string, init?: RequestInit) => { + if (path === '/v1/session' && init?.method === 'GET') return { ok: false, status: 401, json: async () => ({}) }; + if (expired) return { ok: false, status: 401, json: async () => ({ detail: 'Browser session is invalid or expired' }) }; + const result = path === '/v1/session' ? { csrf_token: 'csrf-fixture' } + : path.startsWith('/v1/admin/credentials?') ? [key] : []; + return { ok: true, json: async () => result }; + })); + const user = userEvent.setup(); + render(); + await user.click(await screen.findByRole('button', { name: 'Use an existing credential' })); + await user.type(screen.getByLabelText('GEWU administrator credential'), 'admin-fixture'); + await user.click(screen.getByRole('button', { name: 'Connect' })); + await user.click(await screen.findByRole('tab', { name: 'Credentials' })); + await screen.findByRole('heading', { name: 'Credentials' }); + expired = true; + await user.click(screen.getByRole('button', { name: 'Refresh' })); + expect(await screen.findByLabelText('Username')).toBeVisible(); + expect(screen.queryByText('reader-key')).toBeNull(); + expect(screen.getByRole('status')).toHaveTextContent('Your session ended.'); +}); + +it('restores the admin session after remount and stops restoring after sign-out', async () => { + let active = true; + const storage = vi.spyOn(Storage.prototype, 'setItem'); + const fetchMock = vi.fn(async (path: string, init?: RequestInit) => { + if (path === '/v1/session' && init?.method === 'GET') { + return active ? { ok: true, json: async () => ({ csrf_token: 'restored-csrf', password_account: true }) } + : { ok: false, status: 401, json: async () => ({}) }; + } + if (path === '/v1/session' && init?.method === 'DELETE') active = false; + const result = path.startsWith('/v1/admin/credentials?') ? [key] + : path === '/v1/me' ? { principal_id: 'user:admin' } : []; + return { ok: true, json: async () => result }; + }); + vi.stubGlobal('fetch', fetchMock); + const user = userEvent.setup(); + const first = render(); + expect(await screen.findByRole('heading', { name: 'Projects' })).toBeVisible(); + expect(screen.queryByLabelText('Username')).toBeNull(); + expect(screen.getAllByRole('tab').map(tab => tab.textContent)).toEqual(['Projects', 'Users', 'Credentials', 'Logs', 'Admin Settings']); + expect(screen.getAllByRole('tabpanel')).toHaveLength(1); + expect(screen.queryByRole('heading', { name: 'Credentials' })).toBeNull(); + await user.type(screen.getByLabelText('Project name'), 'Unfinished research'); + const projectsTab = screen.getByRole('tab', { name: 'Projects' }); + projectsTab.focus(); + await user.keyboard('{ArrowRight}'); + expect(screen.getByRole('tab', { name: 'Users' })).toHaveFocus(); + expect(screen.getByRole('heading', { name: 'User accounts' })).toBeVisible(); + await user.keyboard('{End}'); + expect(screen.getByRole('tab', { name: 'Admin Settings' })).toHaveFocus(); + expect(screen.getByRole('heading', { name: 'Change your password' })).toBeVisible(); + await user.keyboard('{ArrowLeft}'); + expect(screen.getByRole('heading', { name: 'Recent activity' })).toBeVisible(); + await user.keyboard('{Home}'); + expect(projectsTab).toHaveFocus(); + expect(screen.getByLabelText('Project name')).toHaveValue('Unfinished research'); + first.unmount(); + render(); + expect(await screen.findByRole('heading', { name: 'Projects' })).toBeVisible(); + expect(fetchMock.mock.calls.filter(([path, init]) => path === '/v1/session' && init?.method === 'GET')).toHaveLength(2); + expect(fetchMock).toHaveBeenCalledWith('/v1/session', expect.objectContaining({ + method: 'GET', credentials: 'same-origin', cache: 'no-store', headers: { 'X-GEWU-Session': 'restore' }, + })); + await user.click(screen.getByRole('button', { name: 'Disconnect' })); + expect(await screen.findByLabelText('Username')).toBeVisible(); + expect(fetchMock).toHaveBeenCalledWith('/v1/session', expect.objectContaining({ + method: 'DELETE', headers: expect.objectContaining({ 'X-GEWU-CSRF': 'restored-csrf' }), + })); + expect(storage).not.toHaveBeenCalled(); + storage.mockRestore(); +}); diff --git a/web/src/shell/Admin.tsx b/web/src/shell/Admin.tsx new file mode 100644 index 0000000..1a59cb8 --- /dev/null +++ b/web/src/shell/Admin.tsx @@ -0,0 +1,175 @@ +import { useCallback, useMemo, useState } from 'react'; +import CredentialsSection from './CredentialsSection'; +import AccountLogin from './AccountLogin'; +import ProjectsSection from './ProjectsSection'; +import { AccountsSection, PasswordSection } from './AccountControls'; +import { AccountRequestError as AdminRequestError, accountRequest as request } from './account-api'; +import type { components } from '../generated/api'; + +const adminTabs = [ + { id: 'projects', label: 'Projects' }, + { id: 'users', label: 'Users' }, + { id: 'credentials', label: 'Credentials' }, + { id: 'logs', label: 'Logs' }, + { id: 'settings', label: 'Admin Settings' }, +] as const; +type AdminTab = typeof adminTabs[number]['id']; + +type Credential = components['schemas']['CredentialSummary']; +type Activity = components['schemas']['AdminEvent']; +type Project = components['schemas']['ProjectRecord']; + +function Admin({ personal = false }: { personal?: boolean }) { + const [activeTab, setActiveTab] = useState('projects'); + const [passwordAccount, setPasswordAccount] = useState(false); + const [csrf, setCsrf] = useState(''); + const [credentials, setCredentials] = useState([]); + const [events, setEvents] = useState([]); + const [logAccount, setLogAccount] = useState(''); + const [logType, setLogType] = useState(''); + const [logFrom, setLogFrom] = useState(''); + const [logTo, setLogTo] = useState(''); + const [projects, setProjects] = useState<{ project_id: string; title: string }[]>([]); + const [projectRecords, setProjectRecords] = useState([]); + const [currentPrincipal, setCurrentPrincipal] = useState(''); + const [offset, setOffset] = useState(0); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(''); + const [message, setMessage] = useState(personal ? 'Sign in to manage your agent keys.' : 'Sign in to manage accounts and service access.'); + const [issued, setIssued] = useState<{ credential_id: string; token: string } | null>(null); + + const load = useCallback(async (session: string, page = offset) => { + const [keys, activity, availableProjects, identity] = await Promise.all([ + request(`${personal ? '/v1/me/agent-credentials' : '/v1/admin/credentials'}?limit=50&offset=${page}`, session), + personal ? Promise.resolve([] as Activity[]) : request('/v1/admin/events?limit=50', session), + personal ? request('/v1/projects', session).then(ids => ids.map(id => ({ project_id: id, title: id }))) : request('/v1/admin/projects', session), + request<{ principal_id: string }>('/v1/me', session), + ]); + setCredentials(keys); setEvents(activity); setProjects(availableProjects); setOffset(page); + setCurrentPrincipal(identity.principal_id ?? ''); + if (!personal) setProjectRecords(availableProjects as Project[]); + }, [personal, offset]); + const refreshAdminData = useCallback(() => load(csrf), [load, csrf]); + const loadAdminPage = useCallback((page: number) => load(csrf, page), [load, csrf]); + + async function connect(session: { csrf_token: string }, isPasswordAccount: boolean, restored = false) { + setBusy(true); setError(''); + try { + await load(session.csrf_token, 0); + setActiveTab('projects'); + setCsrf(session.csrf_token); setPasswordAccount(isPasswordAccount); + setMessage(personal ? 'Signed in. Manage the keys for your agents below.' : 'Connected. Credentials and recent activity are up to date.'); + } catch (failure) { + if (!restored) await request('/v1/session', session.csrf_token, { method: 'DELETE' }).catch(() => undefined); + throw failure; + } finally { setBusy(false); } + } + + async function perform(action: () => Promise) { + setBusy(true); setError(''); + try { await action(); } + catch (failure) { + if (failure instanceof AdminRequestError && failure.status === 401) { + setCsrf(''); setCredentials([]); setEvents([]); setProjects([]); setIssued(null); + setMessage('Your session ended. Connect again to continue.'); + } + setError(failure instanceof Error ? failure.message : 'Request failed.'); + } + finally { setBusy(false); } + } + + + const userProjectOptions = useMemo(() => projects.filter(project => + !projectRecords.some(record => record.project_id === project.project_id && record.status === 'archived') + ), [projects, projectRecords]); + const filteredEvents = useMemo(() => events.filter(event => + (!logAccount || event.actor === logAccount) && + (!logType || event.action === logType) && + (!logFrom || event.occurred_at.slice(0, 10) >= logFrom) && + (!logTo || event.occurred_at.slice(0, 10) <= logTo) + ), [events, logAccount, logType, logFrom, logTo]); + + return
+ Skip to administration + +
+

SERVICE / ADMINISTRATION

{personal ? 'Connect your agents.' : 'Manage access to your library.'}

+ {csrf && } +
+

{busy ? 'Working…' : message}

+ {error &&

{error}

} + {!csrf ?
+

{personal ? 'YOUR ACCOUNT' : 'ADMINISTRATOR ACCESS'}

Sign in to your account

{personal ? 'Manage separate keys for each agent you use. Your administrator assigns your project access.' : 'Sign in with your username and password. For initial setup, use your existing administrator credential to create the first account.'}

+ +
: <> + {!personal &&
+ {adminTabs.map((tab, index) => )} +
} + {!personal && } + {!personal && } + + {!personal && } + + } +
+
; +} + +export default Admin; diff --git a/web/src/shell/AdminPrimitives.tsx b/web/src/shell/AdminPrimitives.tsx new file mode 100644 index 0000000..df20bf4 --- /dev/null +++ b/web/src/shell/AdminPrimitives.tsx @@ -0,0 +1,36 @@ +import { useEffect, useRef, type ReactNode } from 'react'; + +export function AdminDialog({ open, title, onClose, children, returnFocusRef }: { + open: boolean; + title: string; + onClose: () => void; + children: ReactNode; + returnFocusRef?: { current: HTMLElement | null }; +}) { + const dialogRef = useRef(null); + useEffect(() => { + const dialog = dialogRef.current; + if (!dialog) return; + if (open && !dialog.open) dialog.showModal(); + if (!open && dialog.open) dialog.close(); + }, [open]); + return { event.preventDefault(); onClose(); }} + onClose={() => { onClose(); returnFocusRef?.current?.focus(); }}> +

{title}

+ {children} +
; +} + +export function AdminList({ children, label }: { children: ReactNode; label: string }) { + return
    {children}
; +} + +export function AdminListItem({ title, summary, actions, children }: { + title: string; + summary?: string; + actions: ReactNode; + children?: ReactNode; +}) { + return
  • {title}{summary &&

    {summary}

    }
    {actions}
    {children}
  • ; +} diff --git a/web/src/shell/App.test.tsx b/web/src/shell/App.test.tsx new file mode 100644 index 0000000..eef4e7b --- /dev/null +++ b/web/src/shell/App.test.tsx @@ -0,0 +1,555 @@ +import { cleanup, render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { MemoryRouter, useLocation, useNavigate } from 'react-router'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import App from './App'; + +const node = { + node_id: 'dome:season-visitors', + project_id: 'dome-performance', + title: 'Visitor count during the season', + summary: 'The installation hosted 400 visitors.', + node_type: 'finding', + tags: ['real-time', 'installation'], + audience: ['developer'], + use_for: ['planning'], + domain: ['interactive systems'], + assumptions: [{ + text: 'The cited seasonal count is useful when comparing visitor patterns.', + evidence_type: 'reported', + assessment: { + status: 'supported', + method: 'paired source claim check', + rationale: 'The claim is recorded with a direct source reference.', + assessor_id: 'agent-pilot', + }, + }], + limitations: [{ + text: 'The source does not identify unique visitors.', + evidence_type: 'reported', + assessment: { + status: 'supported', + method: 'source completeness review', + rationale: 'The cited material does not provide unique visitor counts.', + assessor_id: 'agent-pilot', + }, + }], + lifecycle: 'active', + as_of: '2026-10-13T00:00:00Z', + claims: [{ + text: 'The installation hosted 400 visitors during the season.', + evidence_type: 'reported', + assessment: { + status: 'supported', + method: 'paired source claim check', + rationale: 'The cited sentence states the count.', + assessor_id: 'agent-pilot', + }, + }], +}; + +const citation = { + source_revision_id: 'dome:pilot-report:r1', + extraction_id: 'a'.repeat(64), + quote: 'The installation hosted 400 visitors during the season.', + locator: { kind: 'text_span', block_id: 'block-1', start: 0, end: 54 }, +}; + +function mockFetch( + searchMode: 'normal' | 'empty' | 'error' = 'normal', + resultNode = node, + resultCitation = citation, + restoreSession = false, +) { + const fetchMock = vi.fn(async (...args: [RequestInfo | URL, RequestInit?]) => { + const [input] = args; + const path = String(input); + let data: unknown; + if (path === '/v1/session' && args[1]?.method === 'GET') return restoreSession ? { ok: true, json: async () => ({ csrf_token: 'restored-csrf', password_account: true }) } as Response : { ok: false, status: 401, json: async () => ({}) } as Response; + if (path === '/v1/session') data = { csrf_token: 'gwc_test-token', expires_at: '2026-10-13T08:00:00Z' }; + else if (path === '/v1/projects') data = ['dome-performance']; + else if (path === '/v1/search' && searchMode === 'error') { + return { ok: false, status: 503, json: async () => ({ detail: 'Search index unavailable.' }) } as Response; + } + else if (path === '/v1/search' && searchMode === 'empty') data = { + data: [], citations: [], warnings: [], omissions: [], + canonical_revision: '1'.repeat(40), index_revision: '1'.repeat(40), next_cursor: null, + }; + else if (path === '/v1/search') data = { + data: [{ node: resultNode, retrieval_score: -1.2 }], + citations: [resultCitation], + warnings: [], + omissions: [], + canonical_revision: '1'.repeat(40), + index_revision: '1'.repeat(40), + next_cursor: null, + }; + else if (path.includes('/nodes/') && path.includes(`?revision=${'2'.repeat(40)}`)) data = { + data: [{ ...resultNode, title: 'Earlier canonical title' }], citations: [resultCitation], warnings: [], omissions: [], + canonical_revision: '2'.repeat(40), index_revision: null, next_cursor: null, + }; + else if (path.includes('/nodes/') && !path.endsWith('/history') && !path.includes('?revision=')) data = { + data: [resultNode], citations: [resultCitation], warnings: [], omissions: [], + canonical_revision: '3'.repeat(40), index_revision: null, next_cursor: null, + }; + else if (path.includes('/nodes/') && path.includes('?revision=')) data = { + data: [resultNode], citations: [resultCitation], warnings: [], omissions: [], + canonical_revision: '1'.repeat(40), index_revision: null, next_cursor: null, + }; + else if (path === '/v1/neighbors') data = { + data: [], citations: [], warnings: [], omissions: [], + canonical_revision: '1'.repeat(40), index_revision: '1'.repeat(40), next_cursor: null, + }; + else if (path === '/v1/sources') data = { + data: [{ + source: { + source_id: 'dome:pilot-report', + revision_id: 'dome:pilot-report:r1', + title: 'Pilot installation report', + source_type: 'internal', + publisher: 'Pilot team', + original_url: null, + captured_at: '2026-10-13T00:00:00Z', + archive_status: 'archived', + license: 'internal', + reliability_grade: 'medium', + ingested_for: ['dome-performance'], + }, + }], + citations: [], warnings: [], omissions: [], + canonical_revision: '1'.repeat(40), index_revision: '1'.repeat(40), next_cursor: null, + }; + else if (path.endsWith('/history')) data = { + data: [ + { revision: '1'.repeat(40), node }, + { revision: '2'.repeat(40), node: { ...node, title: 'Earlier canonical title' } }, + ], citations: [], warnings: [], omissions: [], + canonical_revision: '1'.repeat(40), index_revision: null, next_cursor: null, + }; + else if (path.includes('/extractions/')) data = { + source_revision_id: resultCitation.source_revision_id, + extraction_id: resultCitation.extraction_id, + parser_name: 'gewu-text', parser_version: '0.1.0', + block_id: resultCitation.locator.block_id, offset: resultCitation.locator.start, + normalized_text: resultCitation.quote, original_text: resultCitation.quote, + normalization_map: Array.from({ length: resultCitation.quote.length }, (_, index) => [index, index + 1]), + }; + else if (path.includes('/sources/')) data = { + data: [{ + revision_id: 'dome:pilot-report:r1', title: 'Pilot installation report', + source_type: 'internal', publisher: 'Pilot team', original_url: null, + captured_at: '2026-10-13T00:00:00Z', archive_status: 'archived', license: 'internal', + }], citations: [], warnings: [], omissions: [], canonical_revision: '1'.repeat(40), + index_revision: null, next_cursor: null, + }; + else data = { + data: [resultNode], citations: [resultCitation], warnings: [], omissions: [], + canonical_revision: '1'.repeat(40), index_revision: null, next_cursor: null, + }; + return { ok: true, json: async () => data } as Response; + }); + vi.stubGlobal('fetch', fetchMock); + return fetchMock; +} + +function CurrentRoute() { + const location = useLocation(); + return {location.search}; +} + +function HistoryControls() { + const navigate = useNavigate(); + return ( +
    + + +
    + ); +} + +afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); +}); + +describe('GEWU research reader', () => { + it('connects, searches, reads exact evidence, and opens authorized source metadata', async () => { + const user = userEvent.setup(); + const fetchMock = mockFetch(); + render(); + + await user.click(await screen.findByRole('button', { name: 'Use an existing credential' })); + await user.type(screen.getByLabelText('GEWU viewer credential'), 'viewer-secret'); + await user.click(screen.getByRole('button', { name: 'Connect' })); + expect(await screen.findByLabelText('PROJECT')).toHaveValue('dome-performance'); + await user.click(screen.getByRole('link', { name: /stale knowledge/i })); + expect(screen.getByLabelText('Past freshness date')).toBeChecked(); + await user.type(screen.getByRole('textbox', { name: /search claims/i }), 'visitors'); + await user.type(screen.getByLabelText('As of from'), '2024-01-01'); + await user.type(screen.getByLabelText('As of to'), '2024-12-31'); + await user.click(screen.getByLabelText('medium')); + await user.click(screen.getByRole('button', { name: 'Search' })); + const result = await screen.findByRole('button', { name: /visitor count during the season/i }); + expect(result).toBeVisible(); + + await user.click(result); + expect(screen.getByLabelText('Current search URL')).toHaveTextContent('node=dome%3Aseason-visitors'); + expect((await screen.findAllByText(citation.quote, { exact: false })).length).toBeGreaterThan(0); + expect(await screen.findByRole('heading', { name: 'Claim assessments' })).toBeVisible(); + expect(await screen.findByRole('heading', { name: 'Assumptions and limitations' })).toBeVisible(); + expect(screen.getByText(/The source does not identify unique visitors\./)).toBeVisible(); + expect(screen.getByText('The cited sentence states the count.')).toBeVisible(); + expect(screen.getByText(/not a truth probability/)).toBeVisible(); + expect(await screen.findByRole('heading', { name: 'Relationships' })).toBeVisible(); + expect(screen.getByRole('region', { name: 'Knowledge facets' })).toHaveTextContent('real-time · installation'); + expect(screen.getByText(/No accepted relationships are visible/)).toBeVisible(); + await user.click(screen.getByRole('button', { name: 'Find related suggestions' })); + expect(await screen.findByText(/Related suggestions loaded/)).toBeVisible(); + await user.click(screen.getByRole('button', { name: /open source record/i })); + expect((await screen.findAllByRole('heading', { name: 'Pilot installation report' })).length) + .toBeGreaterThan(0); + expect( + screen.getAllByRole('link', { name: 'Download archived revision' }).some( + (link) => + link.getAttribute('href') === + '/v1/projects/dome-performance/sources/dome%3Apilot-report%3Ar1/artifact', + ), + ).toBe(true); + await user.click(screen.getByRole('button', { name: /open exact source location/i })); + expect(await screen.findByRole('region', { name: 'Exact source location' })).toBeVisible(); + expect(screen.getByText('Original captured text')).toBeVisible(); + expect(screen.getAllByText(citation.quote, { selector: 'pre' })).toHaveLength(2); + expect(fetchMock).toHaveBeenCalledWith( + expect.stringContaining('/extractions/'), + expect.objectContaining({ credentials: 'same-origin' }), + ); + + expect(fetchMock).toHaveBeenCalledWith( + '/v1/session', + expect.objectContaining({ headers: { Authorization: 'Bearer viewer-secret' } }), + ); + expect(fetchMock).toHaveBeenCalledWith( + '/v1/search', + expect.objectContaining({ + credentials: 'same-origin', + headers: expect.objectContaining({ 'X-GEWU-CSRF': 'gwc_test-token' }), + }), + ); + const searchCall = fetchMock.mock.calls.find(([url]) => String(url) === '/v1/search'); + expect(JSON.parse(String(searchCall?.[1]?.body))).toMatchObject({ + stale_only: true, + confidences: ['medium'], + as_of_from: '2024-01-01', + as_of_to: '2024-12-31', + }); + expect(localStorage.length).toBe(0); + expect(screen.queryByRole('button', { name: /approve|edit|merge|generate|capture/i })).not.toBeInTheDocument(); + await user.click(screen.getByRole('link', { name: /sources/i })); + await user.click(screen.getByRole('button', { name: 'Load source records' })); + expect(await screen.findByRole('heading', { name: 'Sources' })).toBeVisible(); + expect( + (await screen.findAllByRole('heading', { name: 'Pilot installation report' })).length, + ).toBeGreaterThan(0); + expect( + screen.getAllByRole('link', { name: 'Download archived revision' }).some( + (link) => + link.getAttribute('href') === + '/v1/projects/dome-performance/sources/dome%3Apilot-report%3Ar1/artifact', + ), + ).toBe(true); + }); + + it('shows exact cue times for transcript citations', async () => { + const user = userEvent.setup(); + const transcriptCitation = { + ...citation, + locator: { + kind: 'transcript_timestamp_span', + block_id: 'cue-1', + start: 48, + end: 102, + time_start_ms: 872000, + time_end_ms: 876500, + }, + }; + mockFetch('normal', node, transcriptCitation); + render(); + + await user.click(await screen.findByRole('button', { name: 'Use an existing credential' })); + await user.type(screen.getByLabelText('GEWU viewer credential'), 'viewer-secret'); + await user.click(screen.getByRole('button', { name: 'Connect' })); + await user.type(screen.getByRole('textbox', { name: /search claims/i }), 'visitors'); + await user.click(screen.getByRole('button', { name: 'Search' })); + await user.click(await screen.findByRole('button', { name: /visitor count during the season/i })); + + expect(await screen.findByText(/Transcript · 00:14:32\.000–00:14:36\.500 · cue-1 · 48–102/)).toBeVisible(); + }); + + it('shows a distinct initial reading state and no knowledge mutation controls', async () => { + mockFetch(); + render(); + expect(screen.getByRole('heading', { level: 1, name: /find the evidence behind an idea/i })).toBeVisible(); + expect(await screen.findByLabelText('Username')).toBeVisible(); + expect(screen.queryByRole('button', { name: /approve|edit|merge|generate|capture/i })).not.toBeInTheDocument(); + }); + + it.each([ + ['empty', 'No matching accepted notes'], + ['error', 'Search could not be completed'], + ] as const)('distinguishes %s search outcomes from the initial state', async (mode, heading) => { + const user = userEvent.setup(); + mockFetch(mode); + render(); + await user.click(await screen.findByRole('button', { name: 'Use an existing credential' })); + await user.type(screen.getByLabelText('GEWU viewer credential'), 'viewer-secret'); + await user.click(screen.getByRole('button', { name: 'Connect' })); + await user.type(screen.getByRole('textbox', { name: /search claims/i }), 'unmatched'); + await user.click(screen.getByRole('button', { name: 'Search' })); + expect(await screen.findByRole('heading', { name: heading })).toBeVisible(); + if (mode === 'error') expect(screen.getByText('Search index unavailable.')).toBeVisible(); + }); + + it('routes Problems and Topics through the typed search facets', async () => { + const user = userEvent.setup(); + mockFetch(); + render(); + await user.click(await screen.findByRole('button', { name: 'Use an existing credential' })); + await user.type(screen.getByLabelText('GEWU viewer credential'), 'viewer-secret'); + await user.click(screen.getByRole('button', { name: 'Connect' })); + await user.click(screen.getByRole('link', { name: /problems/i })); + expect(screen.getByLabelText('KNOWLEDGE TYPE')).toHaveValue('problem'); + await user.click(screen.getByRole('link', { name: /topics/i })); + expect(screen.getByLabelText('KNOWLEDGE TYPE')).toHaveValue('concept'); + }); + + it('applies exact tag, audience, use and domain facets and stores them in the URL', async () => { + const user = userEvent.setup(); + const fetchMock = mockFetch(); + render(); + await user.click(await screen.findByRole('button', { name: 'Use an existing credential' })); + await user.type(screen.getByLabelText('GEWU viewer credential'), 'viewer-secret'); + await user.click(screen.getByRole('button', { name: 'Connect' })); + await user.type(screen.getByRole('textbox', { name: /search claims/i }), 'visitors'); + await user.type(screen.getByLabelText('Tag'), 'installation'); + await user.type(screen.getByLabelText('Audience'), 'developer'); + await user.type(screen.getByLabelText('Use'), 'planning'); + await user.type(screen.getByLabelText('Domain'), 'interactive systems'); + expect(fetchMock.mock.calls.filter(([url]) => String(url) === '/v1/search')).toHaveLength(0); + await user.click(screen.getByRole('button', { name: 'Search' })); + + const searchCall = fetchMock.mock.calls.find(([url]) => String(url) === '/v1/search'); + expect(JSON.parse(String(searchCall?.[1]?.body))).toMatchObject({ + tags: ['installation'], + audience: ['developer'], + use_for: ['planning'], + domain: ['interactive systems'], + }); + expect(screen.getByLabelText('Current search URL')).toHaveTextContent( + 'tag=installation&audience=developer&use=planning&domain=interactive+systems', + ); + }); + + it('restores a shared search URL after connecting with its query and exact facets', async () => { + const user = userEvent.setup(); + const fetchMock = mockFetch(); + render( + + + + , + ); + await user.click(await screen.findByRole('button', { name: 'Use an existing credential' })); + await user.type(screen.getByLabelText('GEWU viewer credential'), 'viewer-secret'); + await user.click(screen.getByRole('button', { name: 'Connect' })); + + expect(await screen.findByRole('button', { name: /visitor count during the season/i })).toBeVisible(); + const searchCalls = fetchMock.mock.calls.filter(([url]) => String(url) === '/v1/search'); + expect(searchCalls).toHaveLength(1); + expect(JSON.parse(String(searchCalls[0]?.[1]?.body))).toMatchObject({ + query: 'visitors', + scope: { projects: ['dome-performance'] }, + tags: ['installation'], + audience: ['developer'], + confidences: ['medium'], + as_of_from: '2024-01-01', + as_of_to: '2024-12-31', + }); + expect(screen.getByLabelText('medium')).toBeChecked(); + expect(screen.getByLabelText('As of from')).toHaveValue('2024-01-01'); + expect(screen.getByLabelText('As of to')).toHaveValue('2024-12-31'); + }); + + it('restores prior searches with one Back step and reruns them on browser navigation', async () => { + const user = userEvent.setup(); + const fetchMock = mockFetch(); + render( + + + + + , + ); + await user.click(await screen.findByRole('button', { name: 'Use an existing credential' })); + await user.type(screen.getByLabelText('GEWU viewer credential'), 'viewer-secret'); + await user.click(screen.getByRole('button', { name: 'Connect' })); + const queryBox = screen.getByRole('textbox', { name: /search claims/i }); + await user.type(queryBox, 'visitors'); + await user.click(screen.getByLabelText('medium')); + await user.type(screen.getByLabelText('As of from'), '2024-01-01'); + await user.click(screen.getByRole('button', { name: 'Search' })); + expect(screen.getByLabelText('Current search URL')).toHaveTextContent('confidence=medium'); + await user.clear(queryBox); + await user.type(queryBox, 'methods'); + await user.click(screen.getByLabelText('medium')); + await user.type(screen.getByLabelText('As of to'), '2026-12-31'); + await user.click(screen.getByRole('button', { name: 'Search' })); + await screen.findByText('Search complete. 1 result.'); + + await user.click(screen.getByRole('button', { name: 'Back in history' })); + await screen.findByText('Search complete. 1 result.'); + expect(queryBox).toHaveValue('visitors'); + expect(screen.getByLabelText('Current search URL')).toHaveTextContent('confidence=medium'); + expect(screen.getByLabelText('medium')).toBeChecked(); + expect(screen.getByLabelText('As of from')).toHaveValue('2024-01-01'); + expect(screen.getByLabelText('As of to')).toHaveValue(''); + let searchCalls = fetchMock.mock.calls + .filter(([url]) => String(url) === '/v1/search') + .map(([, init]) => JSON.parse(String(init?.body)).query); + expect(searchCalls).toEqual(['visitors', 'methods', 'visitors']); + + await user.click(screen.getByRole('button', { name: 'Forward in history' })); + await screen.findByText('Search complete. 1 result.'); + expect(queryBox).toHaveValue('methods'); + expect(screen.getByLabelText('medium')).not.toBeChecked(); + expect(screen.getByLabelText('As of from')).toHaveValue('2024-01-01'); + expect(screen.getByLabelText('As of to')).toHaveValue('2026-12-31'); + searchCalls = fetchMock.mock.calls + .filter(([url]) => String(url) === '/v1/search') + .map(([, init]) => JSON.parse(String(init?.body)).query); + expect(searchCalls).toEqual(['visitors', 'methods', 'visitors', 'methods']); + }); + + it('labels the problem reading view and its missing accepted approaches', async () => { + const user = userEvent.setup(); + mockFetch('normal', { ...node, node_type: 'problem' }); + render(); + await user.click(await screen.findByRole('button', { name: 'Use an existing credential' })); + await user.type(screen.getByLabelText('GEWU viewer credential'), 'viewer-secret'); + await user.click(screen.getByRole('button', { name: 'Connect' })); + await user.type(screen.getByRole('textbox', { name: /search claims/i }), 'visitor problem'); + await user.click(screen.getByRole('button', { name: 'Search' })); + await user.click(await screen.findByRole('button', { name: /visitor count during the season/i })); + expect(await screen.findByRole('heading', { name: 'Approaches and related knowledge' })).toBeVisible(); + expect(screen.getByText(/No accepted approaches or other relationships are visible/)).toBeVisible(); + }); + + it('browses problem records within the selected scope without requiring a text query', async () => { + const user = userEvent.setup(); + const fetchMock = mockFetch('normal', { ...node, node_type: 'problem' }); + render(); + await user.click(await screen.findByRole('button', { name: 'Use an existing credential' })); + await user.type(screen.getByLabelText('GEWU viewer credential'), 'viewer-secret'); + await user.click(screen.getByRole('button', { name: 'Connect' })); + await user.click(await screen.findByRole('button', { name: 'Browse problems' })); + + expect(await screen.findByRole('button', { name: /visitor count during the season/i })).toBeVisible(); + const searchCall = fetchMock.mock.calls.find(([url]) => String(url) === '/v1/search'); + expect(JSON.parse(String(searchCall?.[1]?.body))).toMatchObject({ + query: '', + scope: { projects: ['dome-performance'] }, + node_types: ['problem'], + }); + expect(screen.getByLabelText('Current search URL')).toHaveTextContent('type=problem'); + }); + + it('restores a linked canonical record after connecting with a shared node URL', async () => { + const user = userEvent.setup(); + const fetchMock = mockFetch(); + render( + + + + , + ); + await user.click(await screen.findByRole('button', { name: 'Use an existing credential' })); + await user.type(screen.getByLabelText('GEWU viewer credential'), 'viewer-secret'); + await user.click(screen.getByRole('button', { name: 'Connect' })); + + expect(await screen.findByRole('heading', { name: 'Earlier canonical title' })).toBeVisible(); + expect(fetchMock).toHaveBeenCalledWith( + `/v1/projects/dome-performance/nodes/dome%3Aseason-visitors?revision=${'2'.repeat(40)}`, + expect.objectContaining({ credentials: 'same-origin' }), + ); + }); + + it('clears and restores the reader selection with browser back and forward', async () => { + const user = userEvent.setup(); + mockFetch(); + render( + + + + + , + ); + await user.click(await screen.findByRole('button', { name: 'Use an existing credential' })); + await user.type(screen.getByLabelText('GEWU viewer credential'), 'viewer-secret'); + await user.click(screen.getByRole('button', { name: 'Connect' })); + await user.type(screen.getByRole('textbox', { name: /search claims/i }), 'visitors'); + await user.click(screen.getByRole('button', { name: 'Search' })); + await user.click(await screen.findByRole('button', { name: /visitor count during the season/i })); + expect(await screen.findByRole('heading', { name: 'Claim assessments' })).toBeVisible(); + + await user.click(screen.getByRole('button', { name: 'Back in history' })); + expect(await screen.findByText('Select a result to read its accepted summary, exact citations, source metadata, and revision history.')).toBeVisible(); + expect(screen.getByLabelText('Current search URL')).not.toHaveTextContent('node='); + await user.click(screen.getByRole('button', { name: 'Forward in history' })); + expect(await screen.findByRole('heading', { name: 'Claim assessments' })).toBeVisible(); + expect(screen.getByLabelText('Current search URL')).toHaveTextContent('node=dome%3Aseason-visitors'); + }); + + it('loads and shares a selected historical node revision', async () => { + const user = userEvent.setup(); + const fetchMock = mockFetch(); + render(); + await user.click(await screen.findByRole('button', { name: 'Use an existing credential' })); + await user.type(screen.getByLabelText('GEWU viewer credential'), 'viewer-secret'); + await user.click(screen.getByRole('button', { name: 'Connect' })); + await user.type(screen.getByRole('textbox', { name: /search claims/i }), 'visitors'); + await user.click(screen.getByRole('button', { name: 'Search' })); + await user.click(await screen.findByRole('button', { name: /visitor count during the season/i })); + await user.click(await screen.findByText('Revision history (2)')); + const neighborCallsBeforeHistoricalRead = fetchMock.mock.calls.filter( + ([url]) => String(url) === '/v1/neighbors', + ).length; + await user.click(screen.getByRole('button', { name: /earlier canonical title/i })); + + expect(await screen.findByRole('heading', { name: 'Earlier canonical title' })).toBeVisible(); + expect(screen.getByText(/Revision-pinned view\. Relationship links are omitted/)).toBeVisible(); + expect(screen.getByLabelText('Current search URL')).toHaveTextContent('revision=2222222222'); + expect(fetchMock).toHaveBeenCalledWith( + expect.stringContaining('?revision=2222222222'), + expect.objectContaining({ credentials: 'same-origin' }), + ); + expect(fetchMock.mock.calls.filter(([url]) => String(url) === '/v1/neighbors')).toHaveLength( + neighborCallsBeforeHistoricalRead, + ); + await user.click(screen.getByRole('button', { name: 'Back in history' })); + expect(await screen.findByRole('heading', { name: 'Visitor count during the season' })).toBeVisible(); + expect(screen.getByLabelText('Current search URL')).not.toHaveTextContent('revision='); + await user.click(screen.getByRole('button', { name: 'Forward in history' })); + expect(await screen.findByRole('heading', { name: 'Earlier canonical title' })).toBeVisible(); + expect(screen.getByLabelText('Current search URL')).toHaveTextContent('revision=2222222222'); + }); +}); + + +it('restores library access from the existing browser session without entering a password', async () => { + const fetchMock = mockFetch('normal', node, citation, true); + render(); + expect(await screen.findByLabelText('PROJECT')).toHaveValue('dome-performance'); + expect(screen.queryByLabelText('Username')).toBeNull(); + expect(fetchMock).toHaveBeenCalledWith('/v1/session', expect.objectContaining({ + method: 'GET', credentials: 'same-origin', headers: { 'X-GEWU-Session': 'restore' }, + })); +}); diff --git a/web/src/shell/App.tsx b/web/src/shell/App.tsx new file mode 100644 index 0000000..18a3cb4 --- /dev/null +++ b/web/src/shell/App.tsx @@ -0,0 +1,1401 @@ +import AccountLogin from './AccountLogin'; +import { useEffect, useMemo, useRef, useState, type FormEvent } from 'react'; +import { useSearchParams } from 'react-router'; +import type { components, paths } from '../generated/api'; + +type Envelope = components['schemas']['ReadEnvelope']; +type Citation = components['schemas']['EvidenceReference']; +type SourceRecord = components['schemas']['SourceRevisionRecord']; +type SearchRequestBody = paths['/v1/search']['post']['requestBody']['content']['application/json']; +type NeighborRequestBody = paths['/v1/neighbors']['post']['requestBody']['content']['application/json']; +type SourceListRequestBody = paths['/v1/sources']['post']['requestBody']['content']['application/json']; + +function formatTranscriptTime(milliseconds: number): string { + const hours = Math.floor(milliseconds / 3_600_000); + const minutes = Math.floor((milliseconds % 3_600_000) / 60_000); + const seconds = Math.floor((milliseconds % 60_000) / 1_000); + const millis = milliseconds % 1_000; + return `${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}.${String(millis).padStart(3, '0')}`; +} + +type LocationPreview = { + source_revision_id: string; + extraction_id: string; + parser_name: string; + parser_version: string; + block_id: string; + offset: number; + normalized_text: string; + original_text: string; + normalization_map: Array<[number, number]>; +}; + +type AssessedClaim = { + text: string; + evidence_type: string; + assessment: { + status: string; + method: string; + rationale: string; + assessor_id: string; + }; +}; + +type SearchHit = { + node: { + node_id: string; + project_id: string; + title: string; + summary: string; + node_type: string; + lifecycle: string; + as_of: string; + stale_after?: string | null; + confidence?: string; + tags?: string[]; + audience?: string[]; + use_for?: string[]; + domain?: string[]; + claims?: AssessedClaim[]; + assumptions?: AssessedClaim[]; + limitations?: AssessedClaim[]; + }; + retrieval_score: number; +}; + +type RelationshipHit = { + edge: { + edge_type: string; + conditions?: string | null; + evidence: Citation[]; + interpretation_rationale?: string | null; + }; + neighbor: { + kind: string; + id: string; + record?: { title?: string; project_id?: string; archive_status?: string }; + }; +}; + +type SearchOutcome = 'idle' | 'loading' | 'success' | 'error'; +type ConfidenceFilter = 'low' | 'medium' | 'high'; + +function parseConfidenceFilter(value: string | null): ConfidenceFilter[] { + return (value ?? '').split(',').filter((item): item is ConfidenceFilter => ( + item === 'low' || item === 'medium' || item === 'high' + )); +} + +type SearchRouteState = { + project: string; + query: string; + nodeType: string; + staleOnly: boolean; + tagFacet: string; + audienceFacet: string; + useFacet: string; + domainFacet: string; + confidences: ConfidenceFilter[]; + asOfFrom: string; + asOfTo: string; +}; + +function searchRouteKey(state: SearchRouteState): string { + return JSON.stringify([ + state.project, state.query.trim(), state.nodeType, state.staleOnly, + state.tagFacet.trim(), state.audienceFacet.trim(), state.useFacet.trim(), state.domainFacet.trim(), + state.confidences, state.asOfFrom, state.asOfTo, + ]); +} + +async function api(path: string, csrfToken: string, init?: RequestInit): Promise { + const response = await fetch(path, { + ...init, + credentials: 'same-origin', + headers: { + ...(init?.body ? { 'Content-Type': 'application/json' } : {}), + ...(init?.method && init.method !== 'GET' ? { 'X-GEWU-CSRF': csrfToken } : {}), + ...init?.headers, + }, + }); + if (!response.ok) { + const body = (await response.json().catch(() => null)) as { detail?: string } | null; + throw new Error(body?.detail ?? `The library returned ${response.status}.`); + } + return (await response.json()) as T; +} + +function text(value: unknown, fallback = ''): string { + return typeof value === 'string' ? value : fallback; +} + +function safeExternalUrl(value: string): string | null { + try { + const parsed = new URL(value); + return parsed.protocol === 'https:' || parsed.protocol === 'http:' ? parsed.href : null; + } catch { + return null; + } +} + +export default function App() { + const [searchParams, setSearchParams] = useSearchParams(); + const [csrfToken, setCsrfToken] = useState(''); + const [projects, setProjects] = useState([]); + const [project, setProject] = useState(searchParams.get('project') ?? ''); + const [query, setQuery] = useState(searchParams.get('q') ?? ''); + const [nodeType, setNodeType] = useState(searchParams.get('type') ?? ''); + const [staleOnly, setStaleOnly] = useState(searchParams.get('stale') === 'true'); + const [tagFacet, setTagFacet] = useState(searchParams.get('tag') ?? ''); + const [audienceFacet, setAudienceFacet] = useState(searchParams.get('audience') ?? ''); + const [useFacet, setUseFacet] = useState(searchParams.get('use') ?? ''); + const [domainFacet, setDomainFacet] = useState(searchParams.get('domain') ?? ''); + const [confidences, setConfidences] = useState(parseConfidenceFilter(searchParams.get('confidence'))); + const [asOfFrom, setAsOfFrom] = useState(searchParams.get('as_of_from') ?? ''); + const [asOfTo, setAsOfTo] = useState(searchParams.get('as_of_to') ?? ''); + const [results, setResults] = useState([]); + const [warnings, setWarnings] = useState([]); + const [cursor, setCursor] = useState(null); + const [selected, setSelected] = useState(null); + const [selectedRevision, setSelectedRevision] = useState(searchParams.get('revision') ?? ''); + const [citations, setCitations] = useState([]); + const [history, setHistory] = useState>>([]); + const [relationships, setRelationships] = useState([]); + const [relationshipsUnavailableForRevision, setRelationshipsUnavailableForRevision] = useState(false); + const [relatedSuggestions, setRelatedSuggestions] = useState([]); + const [suggestionsBusy, setSuggestionsBusy] = useState(false); + const [source, setSource] = useState(null); + const [sourceRows, setSourceRows] = useState([]); + const [sourceCursor, setSourceCursor] = useState(null); + const [sourceLoadState, setSourceLoadState] = useState<'idle' | 'loading' | 'loaded' | 'error'>('idle'); + const [locationPreview, setLocationPreview] = useState(null); + const [busy, setBusy] = useState(false); + const [searchOutcome, setSearchOutcome] = useState('idle'); + const [message, setMessage] = useState('Sign in to connect to your GEWU library.'); + const requestGeneration = useRef(0); + const openNodeRef = useRef<( + node: SearchHit['node'], + syncUrl?: boolean, + revision?: string, + ) => Promise>(async () => undefined); + const clearNodeRouteRef = useRef<() => void>(() => undefined); + const pendingNodeRouteRef = useRef<{ nodeId: string; revision: string } | null>(null); + const localUrlUpdateRef = useRef(null); + const lastSubmittedSearchRef = useRef(null); + const searchDraftHistoryRef = useRef(false); + const runSearchRef = useRef<(state: SearchRouteState) => Promise>(async () => undefined); + + const hasCredential = csrfToken.length > 0; + const selectedNodeId = selected?.node_id ?? null; + const currentRevision = selectedRevision; + + const facetOptions = useMemo(() => ({ + tags: [...new Set(results.flatMap((hit) => hit.node.tags ?? []))].sort(), + audience: [...new Set(results.flatMap((hit) => hit.node.audience ?? []))].sort(), + use: [...new Set(results.flatMap((hit) => hit.node.use_for ?? []))].sort(), + domain: [...new Set(results.flatMap((hit) => hit.node.domain ?? []))].sort(), + }), [results]); + + function updateSearchUrl( + overrides: Partial<{ + project: string; + query: string; + nodeType: string; + staleOnly: boolean; + tagFacet: string; + audienceFacet: string; + useFacet: string; + domainFacet: string; + confidences: string[]; + asOfFrom: string; + asOfTo: string; + nodeId: string; + nodeProject: string; + nodeRevision: string; + }> = {}, + replace = true, + ) { + const state = { + project, + query, + nodeType, + staleOnly, + tagFacet, + audienceFacet, + useFacet, + domainFacet, + confidences, + asOfFrom, + asOfTo, + nodeId: searchParams.get('node') ?? '', + nodeProject: searchParams.get('node_project') ?? '', + nodeRevision: searchParams.get('revision') ?? '', + ...overrides, + }; + const params = new URLSearchParams({ + ...(state.project ? { project: state.project } : {}), + ...(state.query.trim() ? { q: state.query.trim() } : {}), + ...(state.nodeType ? { type: state.nodeType } : {}), + ...(state.staleOnly ? { stale: 'true' } : {}), + ...(state.tagFacet.trim() ? { tag: state.tagFacet.trim() } : {}), + ...(state.audienceFacet.trim() ? { audience: state.audienceFacet.trim() } : {}), + ...(state.useFacet.trim() ? { use: state.useFacet.trim() } : {}), + ...(state.domainFacet.trim() ? { domain: state.domainFacet.trim() } : {}), + ...(state.confidences.length ? { confidence: state.confidences.join(',') } : {}), + ...(state.asOfFrom ? { as_of_from: state.asOfFrom } : {}), + ...(state.asOfTo ? { as_of_to: state.asOfTo } : {}), + ...(state.nodeId ? { node: state.nodeId } : {}), + ...(state.nodeProject ? { node_project: state.nodeProject } : {}), + ...(state.nodeRevision ? { revision: state.nodeRevision } : {}), + }); + localUrlUpdateRef.current = params.toString(); + setSearchParams(params, { replace }); + } + + clearNodeRouteRef.current = () => updateSearchUrl({ nodeId: '', nodeProject: '', nodeRevision: '' }); + + function updateSearchDraftUrl(overrides: Parameters[0]) { + updateSearchUrl(overrides, searchDraftHistoryRef.current); + searchDraftHistoryRef.current = true; + } + + async function connect(session: { csrf_token: string }, _passwordAccount?: boolean, restored = false) { + setBusy(true); + setMessage('Checking project access…'); + let createdCsrfToken = ''; + try { + createdCsrfToken = session.csrf_token; + setCsrfToken(session.csrf_token); + const visibleProjects = await api('/v1/projects', session.csrf_token); + setProjects(visibleProjects); + const requested = searchParams.get('project'); + const resolved = requested && visibleProjects.includes(requested) ? requested : visibleProjects[0] ?? ''; + setProject(resolved); + setMessage(visibleProjects.length ? 'Connected to your authorized projects.' : 'No readable projects are available for this credential.'); + if (resolved !== requested) { + updateSearchUrl({ project: resolved }); + // Connecting may normalize a missing or inaccessible project in a shared search URL. + // Keep that route eligible for restoration once the session is ready. + localUrlUpdateRef.current = null; + } + } catch (error) { + setCsrfToken(''); + setProjects([]); + setMessage(error instanceof Error ? error.message : 'Could not connect to GEWU.'); + if (createdCsrfToken && !restored) { + void fetch('/v1/session', { + method: 'DELETE', + credentials: 'same-origin', + headers: { 'X-GEWU-CSRF': createdCsrfToken }, + }).catch(() => undefined); + } + } finally { + setBusy(false); + } + } + + async function runSearch( + event?: FormEvent, + nextCursor?: string | null, + overrides: Partial = {}, + ) { + event?.preventDefault(); + const searchState: SearchRouteState = { + project, query, nodeType, staleOnly, tagFacet, audienceFacet, useFacet, domainFacet, + confidences, asOfFrom, asOfTo, ...overrides, + }; + const searchQuery = searchState.query.trim(); + const searchNodeType = searchState.nodeType; + if (!csrfToken || !searchState.project || (!searchQuery && !searchNodeType)) { + setMessage('Choose an authorized project and enter a search term or choose a knowledge type.'); + return; + } + const generation = ++requestGeneration.current; + lastSubmittedSearchRef.current = searchRouteKey(searchState); + setSearchOutcome('loading'); + setBusy(true); + setMessage('Searching accepted knowledge…'); + setSelected(null); + setSource(null); + setLocationPreview(null); + setCitations([]); + setHistory([]); + setRelationships([]); + updateSearchUrl({ + query: searchQuery, + project: searchState.project, + nodeType: searchNodeType, + staleOnly: searchState.staleOnly, + tagFacet: searchState.tagFacet, + audienceFacet: searchState.audienceFacet, + useFacet: searchState.useFacet, + domainFacet: searchState.domainFacet, + confidences: searchState.confidences, + asOfFrom: searchState.asOfFrom, + asOfTo: searchState.asOfTo, + nodeId: '', + nodeProject: '', + nodeRevision: '', + }, true); + searchDraftHistoryRef.current = false; + try { + const envelope = await api('/v1/search', csrfToken, { + method: 'POST', + body: JSON.stringify({ + query: searchQuery, + scope: { projects: [searchState.project], include_shared: projects.includes('shared'), all_projects: false }, + node_types: searchNodeType ? [searchNodeType] : [], + tags: searchState.tagFacet.trim() ? [searchState.tagFacet.trim()] : [], + audience: searchState.audienceFacet.trim() ? [searchState.audienceFacet.trim()] : [], + use_for: searchState.useFacet.trim() ? [searchState.useFacet.trim()] : [], + domain: searchState.domainFacet.trim() ? [searchState.domainFacet.trim()] : [], + confidences: searchState.confidences, + ...(searchState.asOfFrom ? { as_of_from: searchState.asOfFrom } : {}), + ...(searchState.asOfTo ? { as_of_to: searchState.asOfTo } : {}), + stale_only: searchState.staleOnly, + limit: 20, + ...(nextCursor ? { cursor: nextCursor } : {}), + budget_tokens: 12000, + } satisfies SearchRequestBody), + }); + if (generation !== requestGeneration.current) return; + const page = envelope.data.map((row) => row as unknown as SearchHit); + setResults((existing) => (nextCursor ? [...existing, ...page] : page)); + setWarnings((existing) => (nextCursor ? [...existing, ...envelope.warnings] : envelope.warnings)); + setCursor(envelope.next_cursor ?? null); + setSearchOutcome('success'); + setMessage(`${nextCursor ? 'More results loaded.' : 'Search complete.'} ${nextCursor ? '' : `${page.length} result${page.length === 1 ? '' : 's'}.`}`); + } catch (error) { + if (generation !== requestGeneration.current) return; + setResults([]); + setWarnings([]); + setCursor(null); + setSearchOutcome('error'); + setMessage(error instanceof Error ? error.message : 'Search could not be completed.'); + } finally { + if (generation === requestGeneration.current) setBusy(false); + } + } + + async function loadSources(nextCursor?: string | null) { + if (!csrfToken || !project) return; + const generation = requestGeneration.current; + setSourceLoadState('loading'); + try { + const envelope = await api('/v1/sources', csrfToken, { + method: 'POST', + body: JSON.stringify({ + scope: { projects: [project], include_shared: projects.includes('shared'), all_projects: false }, + limit: 20, + ...(nextCursor ? { cursor: nextCursor } : {}), + budget_tokens: 12000, + } satisfies SourceListRequestBody), + }); + if (generation !== requestGeneration.current) return; + const page = envelope.data.map((row) => (row as unknown as { source: SourceRecord }).source); + setSourceRows((current) => (nextCursor ? [...current, ...page] : page)); + setSourceCursor(envelope.next_cursor ?? null); + setSourceLoadState('loaded'); + setMessage('Loaded ' + page.length + ' source record' + (page.length === 1 ? '' : 's') + ' in the selected scope.'); + } catch (error) { + if (generation !== requestGeneration.current) return; + if (!nextCursor) { + setSourceRows([]); + setSourceCursor(null); + } + setSourceLoadState('error'); + setMessage(error instanceof Error ? error.message : 'Source records could not be loaded.'); + } + } + + runSearchRef.current = async (state) => runSearch(undefined, undefined, state); + + function sourceProject(sourceRecord: SourceRecord): string { + return sourceRecord.ingested_for.includes(project) + ? project + : sourceRecord.ingested_for.includes('shared') && projects.includes('shared') + ? 'shared' + : project; + } + + function selectNodeType(value: string) { + setNodeType(value); + setCursor(null); + setSearchOutcome('idle'); + updateSearchDraftUrl({ nodeType: value }); + } + + function browseProblems() { + setQuery(''); + setNodeType('problem'); + setCursor(null); + setSearchOutcome('idle'); + void runSearch(undefined, undefined, { query: '', nodeType: 'problem' }); + } + + async function openNode(node: SearchHit['node'], syncUrl = true, revision?: string) { + if (!csrfToken) return; + pendingNodeRouteRef.current = { nodeId: node.node_id, revision: revision ?? '' }; + const generation = ++requestGeneration.current; + setBusy(true); + setMessage(`Opening ${node.title}…`); + setSelected(node); + setSelectedRevision(revision ?? ''); + setRelationshipsUnavailableForRevision(Boolean(revision)); + if (syncUrl) { + updateSearchUrl({ + nodeId: node.node_id, + nodeProject: node.project_id, + nodeRevision: revision ?? '', + }, false); + } + setSource(null); + setLocationPreview(null); + try { + const recordPath = `/v1/projects/${encodeURIComponent(node.project_id)}/nodes/${encodeURIComponent(node.node_id)}`; + const [record, revisions, related] = await Promise.all([ + api(`${recordPath}${revision ? `?revision=${encodeURIComponent(revision)}` : ''}`, csrfToken), + api(`/v1/projects/${encodeURIComponent(node.project_id)}/nodes/${encodeURIComponent(node.node_id)}/history`, csrfToken), + revision ? Promise.resolve({ + data: [], + citations: [], + warnings: [], + omissions: [], + canonical_revision: null, + index_revision: null, + next_cursor: null, + }) : api('/v1/neighbors', csrfToken, { + method: 'POST', + body: JSON.stringify({ + node_id: node.node_id, + scope: { projects: [node.project_id], include_shared: projects.includes('shared'), all_projects: false }, + limit: 20, + budget_tokens: 12000, + } satisfies NeighborRequestBody), + }), + ]); + if (generation !== requestGeneration.current) return; + const selectedNodeRevision = revision ?? text(revisions.data[0]?.revision, record.canonical_revision ?? ''); + const canonicalRevision = selectedNodeRevision; + pendingNodeRouteRef.current = { nodeId: node.node_id, revision: revision ?? '' }; + setSelected(record.data[0] as unknown as SearchHit['node']); + setSelectedRevision(canonicalRevision); + updateSearchUrl({ nodeRevision: revision ?? '' }); + setCitations(record.citations); + setHistory(revisions.data); + setRelationships(related.data as unknown as RelationshipHit[]); + setMessage(`${node.title} opened with ${record.citations.length} citation${record.citations.length === 1 ? '' : 's'}.`); + } catch (error) { + if (generation !== requestGeneration.current) return; + setSelected(null); + setSelectedRevision(''); + setRelationshipsUnavailableForRevision(false); + pendingNodeRouteRef.current = null; + updateSearchUrl({ nodeId: '', nodeProject: '', nodeRevision: '' }); + setMessage(error instanceof Error ? error.message : 'The selected note could not be opened.'); + } finally { + if (generation === requestGeneration.current) setBusy(false); + } + } + + openNodeRef.current = openNode; + + async function openSource(revisionId: string) { + if (!csrfToken || !selected) return; + const generation = ++requestGeneration.current; + setBusy(true); + setMessage('Opening source record…'); + try { + const envelope = await api( + `/v1/projects/${encodeURIComponent(selected.project_id)}/sources/${encodeURIComponent(revisionId)}`, + csrfToken, + ); + if (generation !== requestGeneration.current) return; + setSource(envelope.data[0] as unknown as SourceRecord); + setMessage('Source metadata opened.'); + } catch (error) { + if (generation !== requestGeneration.current) return; + setSource(null); + setMessage(error instanceof Error ? error.message : 'Source record could not be opened.'); + } finally { + if (generation === requestGeneration.current) setBusy(false); + } + } + + async function openCitationLocation(citation: Citation) { + if (!csrfToken || !selected) return; + const generation = ++requestGeneration.current; + setBusy(true); + setMessage('Opening the cited source location…'); + try { + const params = new URLSearchParams({ + offset: String(citation.locator.start), + length: String(citation.locator.end - citation.locator.start), + }); + const preview = await api( + `/v1/projects/${encodeURIComponent(selected.project_id)}/sources/${encodeURIComponent(citation.source_revision_id)}/extractions/${encodeURIComponent(citation.extraction_id)}/blocks/${encodeURIComponent(citation.locator.block_id)}?${params}`, + csrfToken, + ); + if (generation !== requestGeneration.current) return; + setLocationPreview(preview); + setMessage('Exact cited source location opened.'); + } catch (error) { + if (generation !== requestGeneration.current) return; + setLocationPreview(null); + setMessage(error instanceof Error ? error.message : 'The cited source location is unavailable.'); + } finally { + if (generation === requestGeneration.current) setBusy(false); + } + } + + function disconnect() { + const activeCsrfToken = csrfToken; + requestGeneration.current += 1; + lastSubmittedSearchRef.current = null; + setCsrfToken(''); + setProjects([]); + setResults([]); + setWarnings([]); + setCursor(null); + setSelected(null); + setSelectedRevision(''); + updateSearchUrl({ nodeId: '', nodeProject: '', nodeRevision: '' }); + setCitations([]); + setHistory([]); + setRelationships([]); + setRelationshipsUnavailableForRevision(false); + setRelatedSuggestions([]); + setSourceRows([]); + setSourceCursor(null); + setSourceLoadState('idle'); + setSource(null); + setLocationPreview(null); + setBusy(false); + setMessage('Disconnected. The credential was cleared from this page.'); + if (activeCsrfToken) { + void fetch('/v1/session', { + method: 'DELETE', + credentials: 'same-origin', + headers: { 'X-GEWU-CSRF': activeCsrfToken }, + }).catch(() => undefined); + } + } + + async function findRelatedSuggestions() { + if (!csrfToken || !selected) return; + const generation = requestGeneration.current; + setSuggestionsBusy(true); + setRelatedSuggestions([]); + try { + const envelope = await api('/v1/search', csrfToken, { + method: 'POST', + body: JSON.stringify({ + query: selected.title, + scope: { projects: [selected.project_id], include_shared: projects.includes('shared'), all_projects: false }, + limit: 10, + stale_only: false, + budget_tokens: 6000, + } satisfies SearchRequestBody), + }); + if (generation !== requestGeneration.current) return; + setRelatedSuggestions( + envelope.data + .map((row) => row as unknown as SearchHit) + .filter((hit) => hit.node.node_id !== selected.node_id), + ); + setMessage('Related suggestions loaded. These are lexical candidates, not accepted relationships.'); + } catch (error) { + if (generation !== requestGeneration.current) return; + setMessage(error instanceof Error ? error.message : 'Related suggestions could not be loaded.'); + } finally { + setSuggestionsBusy(false); + } + } + + useEffect(() => { + const urlProject = searchParams.get('project'); + const urlQuery = searchParams.get('q'); + const urlNodeType = searchParams.get('type') ?? ''; + const urlStaleOnly = searchParams.get('stale') === 'true'; + if (urlProject !== null) setProject(urlProject); + setQuery(urlQuery ?? ''); + setNodeType(urlNodeType); + setStaleOnly(urlStaleOnly); + setTagFacet(searchParams.get('tag') ?? ''); + setAudienceFacet(searchParams.get('audience') ?? ''); + setUseFacet(searchParams.get('use') ?? ''); + setDomainFacet(searchParams.get('domain') ?? ''); + setConfidences(parseConfidenceFilter(searchParams.get('confidence'))); + setAsOfFrom(searchParams.get('as_of_from') ?? ''); + setAsOfTo(searchParams.get('as_of_to') ?? ''); + const requestedNodeId = searchParams.get('node'); + const requestedNodeProject = searchParams.get('node_project') ?? urlProject ?? ''; + const requestedNodeRevision = searchParams.get('revision') ?? ''; + const routeText = searchParams.toString(); + const isLocalUrlUpdate = localUrlUpdateRef.current === routeText; + if (isLocalUrlUpdate) localUrlUpdateRef.current = null; + if (!requestedNodeId) { + pendingNodeRouteRef.current = null; + if (!selectedNodeId) setSelectedRevision(''); + if (searchParams.has('node_project') || searchParams.has('revision')) { + clearNodeRouteRef.current(); + } + if (selectedNodeId) { + requestGeneration.current += 1; + setBusy(false); + setSelected(null); + setSelectedRevision(''); + setCitations([]); + setHistory([]); + setRelationships([]); + setRelationshipsUnavailableForRevision(false); + setRelatedSuggestions([]); + setSource(null); + setLocationPreview(null); + } + const routeSearch: SearchRouteState = { + project: urlProject ?? project, + query: urlQuery ?? '', + nodeType: urlNodeType, + staleOnly: urlStaleOnly, + tagFacet: searchParams.get('tag') ?? '', + audienceFacet: searchParams.get('audience') ?? '', + useFacet: searchParams.get('use') ?? '', + domainFacet: searchParams.get('domain') ?? '', + confidences: parseConfidenceFilter(searchParams.get('confidence')), + asOfFrom: searchParams.get('as_of_from') ?? '', + asOfTo: searchParams.get('as_of_to') ?? '', + }; + const routeKey = searchRouteKey(routeSearch); + if ( + csrfToken + && !isLocalUrlUpdate + && (routeSearch.query.trim() || routeSearch.nodeType) + && lastSubmittedSearchRef.current !== routeKey + ) void runSearchRef.current(routeSearch); + return; + } + const pendingRoute = pendingNodeRouteRef.current; + if (pendingRoute?.nodeId === requestedNodeId) { + if (busy || pendingRoute.revision !== requestedNodeRevision) return; + pendingNodeRouteRef.current = null; + } + const routeAlreadySelected = selectedNodeId === requestedNodeId && ( + requestedNodeRevision + ? selectedRevision === requestedNodeRevision + : !relationshipsUnavailableForRevision + ); + if ( + !csrfToken + || busy + || routeAlreadySelected + ) return; + const hit = results.find((item) => item.node.node_id === requestedNodeId); + if (hit) { + void openNodeRef.current(hit.node, false, requestedNodeRevision || undefined); + return; + } + if (!requestedNodeProject) return; + const generation = ++requestGeneration.current; + setBusy(true); + setMessage('Restoring the linked knowledge record…'); + void api( + `/v1/projects/${encodeURIComponent(requestedNodeProject)}/nodes/${encodeURIComponent(requestedNodeId)}${requestedNodeRevision ? `?revision=${encodeURIComponent(requestedNodeRevision)}` : ''}`, + csrfToken, + ).then((record) => { + if (generation !== requestGeneration.current) return; + void openNodeRef.current( + record.data[0] as unknown as SearchHit['node'], + false, + requestedNodeRevision || undefined, + ); + }).catch((error: unknown) => { + if (generation !== requestGeneration.current) return; + setSelected(null); + setSelectedRevision(''); + setRelationshipsUnavailableForRevision(false); + pendingNodeRouteRef.current = null; + clearNodeRouteRef.current(); + setMessage(error instanceof Error ? error.message : 'The linked knowledge record could not be opened.'); + }).finally(() => { + if (generation === requestGeneration.current) setBusy(false); + }); + }, [ + searchParams, + csrfToken, + project, + results, + selectedNodeId, + selectedRevision, + relationshipsUnavailableForRevision, + busy, + ]); + + return ( +
    + Skip to research + + +
    +
    +
    +

    RESEARCH / SEARCH

    +

    Find the evidence behind an idea.

    +
    + {hasCredential ? ( + + ) : null} +
    + + {!hasCredential ? ( +
    +
    +

    PRIVATE LIBRARY

    +

    Sign in to your library

    +

    Use the username and password provided by your administrator. Browser sessions expire within eight hours.

    +
    + { if (error) setMessage(error); }} /> +
    + ) : ( + <> + + +
    +
    +
    +
    +

    KNOWLEDGE INDEX

    +

    Search results

    +
    + {results.length} loaded +
    + {warnings.length ? ( +
      + {warnings.map((warning, index) => ( +
    • + {warning.code === 'INDEX_LAG' ? 'Index behind' : warning.code.replaceAll('_', ' ')} + {warning.message} +
    • + ))} +
    + ) : null} + {results.length === 0 ? ( +
    +

    + {searchOutcome === 'loading' + ? 'Searching accepted knowledge…' + : searchOutcome === 'error' + ? 'Search could not be completed' + : searchOutcome === 'success' + ? 'No matching accepted notes' + : 'Start with a question or phrase'} +

    +

    + {searchOutcome === 'loading' + ? 'Results will appear here when the scoped search finishes.' + : searchOutcome === 'error' + ? 'Check the status message for the service response, then retry the search.' + : searchOutcome === 'success' + ? 'No accepted notes matched this query in the selected project scope.' + : 'Search within the selected project to find cited knowledge.'} +

    +
    + ) : ( + <> +
      + {results.map((hit) => { + const isStale = hit.node.stale_after + ? Date.parse(hit.node.stale_after) <= Date.now() + : false; + return ( +
    • + +
    • + ); + })} +
    + + )} + {cursor ? ( + + ) : null} +
    + +
    + {selected ? ( + <> +
    +

    {selected.node_type} · {selected.project_id}

    +

    {selected.title}

    +

    {selected.summary}

    + {selectedRevision ?

    Canonical revision {selectedRevision}

    : null} + {relationshipsUnavailableForRevision ? ( +

    Revision-pinned view. Relationship links are omitted because the available index reflects current accepted state.

    + ) : null} + {selected.stale_after ?

    Freshness review date: {selected.stale_after}

    : null} + {[...(selected.tags ?? []), ...(selected.audience ?? []), ...(selected.use_for ?? []), ...(selected.domain ?? [])].length ? ( +
    + {selected.tags?.length ?

    Tags {selected.tags.join(' · ')}

    : null} + {selected.audience?.length ?

    Audience {selected.audience.join(' · ')}

    : null} + {selected.use_for?.length ?

    Use {selected.use_for.join(' · ')}

    : null} + {selected.domain?.length ?

    Domain {selected.domain.join(' · ')}

    : null} +
    + ) : null} +
    +
    +
    +

    SOURCE SUPPORT

    Evidence

    + {citations.length} citations +
    + {citations.length ? ( +
      + {citations.map((citation, index) => ( +
    1. +
      “{citation.quote}”
      +

      + {citation.locator.kind === 'pdf_page_span' + ? `Page ${citation.locator.page ?? 'unknown'}` + : citation.locator.kind === 'transcript_timestamp_span' + ? `Transcript · ${formatTranscriptTime(citation.locator.time_start_ms ?? 0)}–${formatTranscriptTime(citation.locator.time_end_ms ?? 0)} · ${citation.locator.block_id} · ${citation.locator.start}–${citation.locator.end}` + : `Text · ${citation.locator.block_id} · ${citation.locator.start}–${citation.locator.end}`} +

      + + + {locationPreview?.source_revision_id === citation.source_revision_id + && locationPreview.extraction_id === citation.extraction_id + && locationPreview.block_id === citation.locator.block_id + && locationPreview.offset === citation.locator.start ? ( +
      +

      ARCHIVED EXTRACT · {locationPreview.parser_name} {locationPreview.parser_version}

      +

      Normalized text

      +
      {locationPreview.normalized_text}
      +

      Original captured text

      +
      {locationPreview.original_text}
      +

      Original character spans: {locationPreview.normalization_map.map(([start, end]) => `${start}–${end}`).join(', ')}

      +
      + ) : null} +
    2. + ))} +
    + ) :

    No citations are attached to this note.

    } +
    + {selected.claims?.length ? ( +
    +
    +

    RECORDED REVIEW

    Claim assessments

    + {selected.claims.length} +
    +
      + {selected.claims.map((claim, index) => ( +
    1. +
      “{claim.text}”
      +

      Evidence type {claim.evidence_type.replaceAll('_', ' ')}

      +

      Assessment status {claim.assessment.status.replaceAll('_', ' ')}

      +

      Method {claim.assessment.method}

      +

      Rationale {claim.assessment.rationale}

      +

      Assessed by {claim.assessment.assessor_id}. This records the review decision and its basis; it is not a truth probability.

      +
    2. + ))} +
    +
    + ) : null} + {selected.assumptions?.length || selected.limitations?.length ? ( +
    +
    +

    APPLICABILITY

    Assumptions and limitations

    + {(selected.assumptions?.length ?? 0) + (selected.limitations?.length ?? 0)} +
    + {(['assumptions', 'limitations'] as const).map((kind) => { + const statements = selected[kind] ?? []; + return statements.length ? ( +
    +

    {kind === 'assumptions' ? 'Assumptions' : 'Limitations'}

    +
      + {statements.map((statement, index) => ( +
    1. +
      “{statement.text}”
      +

      Evidence type {statement.evidence_type.replaceAll('_', ' ')}

      +

      Assessment status {statement.assessment.status.replaceAll('_', ' ')}

      +

      Rationale {statement.assessment.rationale}

      +

      {statement.evidence_type === 'opinion' ? 'Interpretation' : 'Evidence'} · {statement.assessment.method}

      +
    2. + ))} +
    +
    + ) : null; + })} +

    Source quotations and exact locations for these statements are listed in Evidence above.

    +
    + ) : null} + {source ? ( +
    +
    +

    ARCHIVE RECORD

    {source.title}

    +
    +
    +
    Captured
    {source.captured_at}
    +
    Availability
    {source.archive_status}
    +
    License
    {source.license}
    +
    + {safeExternalUrl(source.original_url ?? '') ? Open original source link : null} + {source.archive_status === 'archived' ? ( + + Download archived revision + + ) :

    No archived bytes are available, so this source cannot support factual evidence.

    } +
    + ) : null} +
    +
    +

    ACCEPTED LINKS

    {selected.node_type === 'problem' ? 'Approaches and related knowledge' : 'Relationships'}

    + {relationships.length} +
    + {relationships.length ? ( +
      + {relationships.map((item, index) => ( +
    • +

      {item.edge.edge_type.replaceAll('_', ' ')}

      +

      {item.neighbor.record?.title ?? item.neighbor.id}

      + {item.neighbor.record?.project_id ?

      {item.neighbor.record.project_id}

      : null} + {item.edge.conditions ?

      Conditions {item.edge.conditions}

      : null} + {item.edge.interpretation_rationale ?

      Interpretation {item.edge.interpretation_rationale}

      : null} + {item.edge.evidence.length ?

      {item.edge.evidence.length} supporting citation{item.edge.evidence.length === 1 ? '' : 's'} available in the relationship record.

      : null} +
    • + ))} +
    + ) :

    {relationshipsUnavailableForRevision ? 'Relationship data is unavailable for this pinned node revision.' : selected.node_type === 'problem' ? 'No accepted approaches or other relationships are visible in this project scope. Related suggestions are not shown as proven solutions.' : 'No accepted relationships are visible in this project scope. Related suggestions are not shown as accepted links.'}

    } +

    Only relationships visible in the selected project/shared scope are listed. Names and counts from excluded projects are not disclosed.

    + {!relationshipsUnavailableForRevision ? : null} + {relatedSuggestions.length ? ( +
    +

    RELATED SUGGESTIONS · NOT ACCEPTED LINKS

    +
      + {relatedSuggestions.map((hit) => ( +
    • + +
    • + ))} +
    +
    + ) : null} +
    + {history.length > 1 ? ( +
    + Revision history ({history.length}) +
      + {history.map((entry, index) => { + const oldNode = entry.node as SearchHit['node'] | undefined; + const revision = text(entry.revision, ''); + return ( +
    1. + +
    2. + ); + })} +
    +
    + ) : null} + + ) : ( +
    + +

    Evidence stays close.

    +

    Select a result to read its accepted summary, exact citations, source metadata, and revision history.

    +
    + )} +
    +
    +
    +
    +
    +

    AUTHORIZED ARCHIVE

    +

    Sources

    +
    + +
    + {sourceLoadState === 'error' ? ( +

    Source records could not be loaded. Check the status message and retry.

    + ) : null} + {sourceLoadState === 'loaded' && sourceRows.length === 0 ? ( +

    No source records are available in this project scope.

    + ) : null} + {sourceRows.length ? ( + <> +
      + {sourceRows.map((sourceRecord) => ( +
    • +
      +

      {sourceRecord.source_type} · {sourceRecord.reliability_grade} reliability

      +

      {sourceRecord.title}

      +

      {sourceRecord.publisher ?? 'Publisher not recorded'} · Captured {sourceRecord.captured_at}

      +

      {sourceRecord.archive_status} · {sourceRecord.license}

      +

      Revision {sourceRecord.revision_id}

      +
      +
      + {safeExternalUrl(sourceRecord.original_url ?? '') ? ( + Open original link + ) : null} + {sourceRecord.archive_status === 'archived' ? ( + + Download archived revision + + ) : No archived bytes} +
      +
    • + ))} +
    + {sourceCursor ? ( + + ) : null} + + ) : null} +
    + + )} +

    {message}

    +
    + Read-only research workspace + {currentRevision ? `Selected ${currentRevision}` : 'Knowledge changes happen through authorized agent workflows.'} +
    +
    +
    + ); +} diff --git a/web/src/shell/CredentialsSection.tsx b/web/src/shell/CredentialsSection.tsx new file mode 100644 index 0000000..6d28ab4 --- /dev/null +++ b/web/src/shell/CredentialsSection.tsx @@ -0,0 +1,82 @@ +import { memo, useRef, useState, type FormEvent } from 'react'; +import type { components } from '../generated/api'; +import { accountRequest } from './account-api'; +import { AdminDialog, AdminList, AdminListItem } from './AdminPrimitives'; + +type Credential = components['schemas']['CredentialSummary']; +type Project = { project_id: string; title: string }; +type Issued = { credential_id: string; token: string }; + +function CredentialsSection({ csrf, personal, credentials, projects, offset, onPageChange, onRefresh, onIssued, onError, onMessage }: { + csrf: string; personal: boolean; credentials: Credential[]; projects: Project[]; offset: number; + onPageChange: (page: number) => Promise; onRefresh: () => Promise; onIssued: (issued: Issued) => void; + onError: (error: string) => void; onMessage: (message: string) => void; +}) { + const [addOpen, setAddOpen] = useState(false); + const addButtonRef = useRef(null); + const [credentialId, setCredentialId] = useState(''); + const [principalId, setPrincipalId] = useState(''); + const [role, setRole] = useState(personal ? 'agent' : 'viewer'); + const [grants, setGrants] = useState([]); + const [editId, setEditId] = useState(null); + const [editRole, setEditRole] = useState('viewer'); + const [editGrants, setEditGrants] = useState([]); + const [revokeId, setRevokeId] = useState(null); + const [busy, setBusy] = useState(false); + + async function perform(action: () => Promise) { + setBusy(true); onError(''); + try { await action(); } + catch (error) { onError(error instanceof Error ? error.message : 'Credential operation failed.'); } + finally { setBusy(false); } + } + async function create(event: FormEvent) { + event.preventDefault(); + await perform(async () => { + const result = await accountRequest(personal ? '/v1/me/agent-credentials' : '/v1/admin/credentials', csrf, { + method: 'POST', body: JSON.stringify(personal ? { name: credentialId, project_ids: grants } : { credential_id: credentialId, principal_id: principalId, role, project_ids: grants }), + }); + onIssued(result); setAddOpen(false); setCredentialId(''); setPrincipalId(''); setGrants([]); + onMessage('Credential created. Save its secret before dismissing it.'); await onRefresh(); + }); + } + function startEdit(key: Credential) { setEditId(key.credential_id); setEditRole(key.roles[0] ?? 'viewer'); setEditGrants(key.project_ids); setRevokeId(null); } + async function saveEdit(event: FormEvent) { + event.preventDefault(); if (!editId) return; + await perform(async () => { + await accountRequest(`/v1/admin/credentials/${encodeURIComponent(editId)}`, csrf, { method: 'PATCH', body: JSON.stringify({ role: editRole, project_ids: editRole === 'admin' ? [] : editGrants }) }); + onMessage(`Updated ${editId}.`); setEditId(null); await onRefresh(); + }); + } + return
    +

    Credentials

    Manage service keys and project grants.

    + +
    + {credentials.map(key => {!personal && }}> + {editId === key.credential_id &&
    + + {editRole !== 'admin' &&
    Project access{projects.map(project => )}
    } +
    +
    } + {revokeId === key.credential_id &&

    Revoke this key? Its sessions and agent access will stop.

    } +
    )}
    + {!credentials.length &&

    No credentials on this page.

    } +
    Page {Math.floor(offset / 50) + 1}
    + setAddOpen(false)} returnFocusRef={addButtonRef}> +
    + + {!personal && } + {!personal && } +
    Project access{role === 'admin' ?

    All projects

    : projects.map(project => )}
    + +
    +
    +
    ; +} + +export default memo(CredentialsSection); diff --git a/web/src/shell/ProjectsSection.test.tsx b/web/src/shell/ProjectsSection.test.tsx new file mode 100644 index 0000000..501f8e7 --- /dev/null +++ b/web/src/shell/ProjectsSection.test.tsx @@ -0,0 +1,60 @@ +import { cleanup, render, screen, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { afterEach, expect, it, vi } from 'vitest'; +import Admin from './Admin'; + +afterEach(() => { cleanup(); vi.unstubAllGlobals(); }); + +it('creates a project from the admin form and makes it available for account and agent grants', async () => { + let created = false; + let conflict = true; + const project = { + schema_version: 1, project_id: 'my-research', title: 'My research', status: 'active', + north_star: 'Investigate useful techniques.', key_questions: ['What works?', 'Under what conditions?'], + owners: ['user:admin'], autonomy_policy_id: 'my-research-policy', + }; + const fetchMock = vi.fn(async (path: string, init?: RequestInit) => { + if (path === '/v1/session' && init?.method === 'GET') return { ok: false, status: 401, json: async () => ({}) }; + let result: unknown = []; + if (path === '/v1/users/session') result = { csrf_token: 'csrf-fixture' }; + if (path === '/v1/me') result = { principal_id: 'user:admin' }; + if (path === '/v1/admin/projects') { + if (init?.method === 'POST') { + if (conflict) return { ok: false, status: 409, json: async () => ({ detail: 'Project ID is already registered' }) }; + created = true; result = project; + } else result = created ? [project] : []; + } + return { ok: true, json: async () => result }; + }); + vi.stubGlobal('fetch', fetchMock); + const user = userEvent.setup(); + render(); + await user.type(await screen.findByLabelText('Username'), 'admin'); + await user.type(screen.getByLabelText('Password'), 'password-fixture'); + await user.click(screen.getByRole('button', { name: 'Sign in' })); + const projects = await screen.findByRole('region', { name: 'Projects' }); + expect(within(projects).getByText('No projects yet. Create your first project below.')).toBeVisible(); + expect(within(projects).getByLabelText(/Owner ID/)).toHaveValue('user:admin'); + await user.type(within(projects).getByLabelText('Project name'), 'My research'); + expect(within(projects).getByLabelText(/Project ID/)).toHaveValue('my-research'); + await user.type(within(projects).getByLabelText('Project purpose'), 'Investigate useful techniques.'); + await user.type(within(projects).getByLabelText('Key research questions'), 'What works?\nUnder what conditions?'); + await user.click(within(projects).getByRole('button', { name: 'Create project' })); + expect(await screen.findByRole('alert')).toHaveTextContent('Project ID is already registered'); + expect(within(projects).getByLabelText('Project name')).toHaveValue('My research'); + conflict = false; + await user.click(within(projects).getByRole('button', { name: 'Create project' })); + await screen.findByText('Created My research. It is now available for user access and agent keys.'); + expect(fetchMock).toHaveBeenCalledWith('/v1/admin/projects', expect.objectContaining({ + method: 'POST', headers: expect.objectContaining({ 'X-GEWU-CSRF': 'csrf-fixture' }), + body: JSON.stringify({ + project: { project_id: 'my-research', title: 'My research', status: 'active', owners: ['user:admin'], north_star: 'Investigate useful techniques.', key_questions: ['What works?', 'Under what conditions?'], autonomy_policy_id: 'my-research-policy', default_includes: [] }, + policy: { policy_id: 'my-research-policy', policy_version: 1, project_id: 'my-research', automatic_source_registration: true, automatic_node_types: ['finding', 'problem', 'technique', 'case', 'entity', 'event', 'concept', 'datapoint', 'synthesis'], require_supported_assessment: true }, + }), + })); + expect(within(projects).getByLabelText('Project name')).toHaveValue(''); + await user.click(screen.getByRole('tab', { name: 'Users' })); + expect(within(screen.getByRole('region', { name: 'User accounts' })).getByLabelText('My research')).toBeVisible(); + await user.click(screen.getByRole('tab', { name: 'Credentials' })); + expect(within(screen.getByRole('region', { name: 'Create a credential' })).getByLabelText('My research')).toBeVisible(); +}); diff --git a/web/src/shell/ProjectsSection.tsx b/web/src/shell/ProjectsSection.tsx new file mode 100644 index 0000000..b58af15 --- /dev/null +++ b/web/src/shell/ProjectsSection.tsx @@ -0,0 +1,195 @@ +import { memo, useRef, useState, type FormEvent } from 'react'; +import type { ReactNode } from 'react'; +import type { components } from '../generated/api'; +import { AdminDialog } from './AdminPrimitives'; +import { accountRequest } from './account-api'; + +type Project = components['schemas']['ProjectRecord']; +const researchTypes = ['finding', 'problem', 'technique', 'case', 'entity', 'event', 'concept', 'datapoint', 'synthesis'] as const; + +function inlineMarkdown(text: string): ReactNode[] { + const pattern = /(`[^`]+`|\*\*[^*]+\*\*|\*[^*]+\*|\[([^\]]+)\]\((https?:\/\/[^\s)]+)\))/g; + const nodes: ReactNode[] = []; + let cursor = 0; + for (const match of text.matchAll(pattern)) { + const index = match.index ?? 0; + if (index > cursor) nodes.push(text.slice(cursor, index)); + const token = match[0]; + if (token.startsWith('`')) nodes.push({token.slice(1, -1)}); + else if (token.startsWith('**')) nodes.push({token.slice(2, -2)}); + else if (token.startsWith('*')) nodes.push({token.slice(1, -1)}); + else nodes.push({match[2]}); + cursor = index + token.length; + } + if (cursor < text.length) nodes.push(text.slice(cursor)); + return nodes; +} + +function ProjectMarkdown({ source }: { source: string }) { + const lines = source.replace(/\r\n?/g, '\n').split('\n'); + const blocks: ReactNode[] = []; + let paragraph: string[] = []; + let list: { ordered: boolean; items: string[] } | null = null; + const flushParagraph = () => { + if (paragraph.length) blocks.push(

    {inlineMarkdown(paragraph.join(' '))}

    ); + paragraph = []; + }; + const flushList = () => { + if (!list) return; + const Tag = list.ordered ? 'ol' : 'ul'; + blocks.push({list.items.map((item, index) =>
  • {inlineMarkdown(item)}
  • )}
    ); + list = null; + }; + + for (const line of lines) { + const heading = /^(#{1,4})\s+(.+)$/.exec(line); + const bullet = /^\s*[-*+]\s+(.+)$/.exec(line); + const numbered = /^\s*\d+[.)]\s+(.+)$/.exec(line); + if (!line.trim()) { flushParagraph(); flushList(); continue; } + if (heading) { + flushParagraph(); flushList(); + const level = heading[1].length; + const content = inlineMarkdown(heading[2]); + if (level === 1) blocks.push(

    {content}

    ); + else if (level === 2) blocks.push(

    {content}

    ); + else if (level === 3) blocks.push(

    {content}

    ); + else blocks.push(
    {content}
    ); + continue; + } + if (bullet || numbered) { + flushParagraph(); + const ordered = !!numbered; + if (list && list.ordered !== ordered) flushList(); + list ??= { ordered, items: [] }; + list.items.push((bullet ?? numbered)![1]); + continue; + } + flushList(); paragraph.push(line.trim()); + } + flushParagraph(); flushList(); + return
    {blocks}
    ; +} + +function ProjectsSection({ csrf, projects, currentOwner, onCreated, onError, onMessage }: { + csrf: string; + projects: Project[]; + currentOwner: string; + onCreated: () => Promise; + onError: (message: string) => void; + onMessage: (message: string) => void; +}) { + const [title, setTitle] = useState(''); + const [slug, setSlug] = useState(''); + const [slugEdited, setSlugEdited] = useState(false); + const [purpose, setPurpose] = useState(''); + const [questions, setQuestions] = useState(''); + const [owner, setOwner] = useState(currentOwner); + const [automatic, setAutomatic] = useState(true); + const [busy, setBusy] = useState(false); + const [createOpen, setCreateOpen] = useState(false); + const createButtonRef = useRef(null); + const viewButtonRef = useRef(null); + const [viewing, setViewing] = useState(null); + const [editing, setEditing] = useState(null); + const [editDescription, setEditDescription] = useState(''); + const [editPurpose, setEditPurpose] = useState(''); + const [editQuestions, setEditQuestions] = useState(''); + + async function create(event: FormEvent) { + event.preventDefault(); onError(''); + const keyQuestions = questions.split('\n').map(q => q.trim()).filter(Boolean); + if (!title.trim() || !purpose.trim() || !keyQuestions.length || keyQuestions.length > 30) { + onError('Enter a project name, purpose, and between 1 and 30 research questions.'); return; + } + setBusy(true); + try { + await accountRequest('/v1/admin/projects', csrf, { + method: 'POST', + body: JSON.stringify({ + project: { + project_id: slug, title: title.trim(), status: 'active', owners: [owner.trim()], + north_star: purpose.trim(), key_questions: keyQuestions, + autonomy_policy_id: `${slug}-policy`, default_includes: [], + }, + policy: { + policy_id: `${slug}-policy`, policy_version: 1, project_id: slug, + automatic_source_registration: automatic, + automatic_node_types: automatic ? [...researchTypes] : [], + require_supported_assessment: true, + }, + }), + }); + setTitle(''); setSlug(''); setSlugEdited(false); setPurpose(''); setQuestions(''); + onMessage(`Created ${title.trim()}. It is now available for user access and agent keys.`); + setCreateOpen(false); + await onCreated(); + } catch (error) { onError(error instanceof Error ? error.message : 'Could not create project.'); } + finally { setBusy(false); } + } + + function beginEdit(project: Project) { + setEditing(project.project_id); + setEditDescription(project.description ?? ''); + setEditPurpose(project.north_star); + setEditQuestions(project.key_questions.join('\n')); + } + + async function saveEdit(event: FormEvent) { + event.preventDefault(); + if (!editing) return; + const keyQuestions = editQuestions.split('\n').map(question => question.trim()).filter(Boolean); + if (!editPurpose.trim() || !keyQuestions.length || keyQuestions.length > 30) { + onError('Enter a project purpose and between 1 and 30 research questions.'); return; + } + setBusy(true); onError(''); + try { + await accountRequest(`/v1/admin/projects/${encodeURIComponent(editing)}`, csrf, { + method: 'PATCH', body: JSON.stringify({ description: editDescription.trim(), north_star: editPurpose.trim(), key_questions: keyQuestions }), + }); + onMessage(`Updated ${editing}.`); setEditing(null); await onCreated(); + } catch (error) { onError(error instanceof Error ? error.message : 'Could not update project.'); } + finally { setBusy(false); } + } + + return
    +

    Projects

    Manage research scopes, descriptions, and questions.

    + +
    + {projects.length ?
      {projects.map(project =>
    • +
      {project.title}
      + + +
      + {editing === project.project_id &&
      +