Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ccb373dee1 | |||
| ee26ff1658 | |||
| b8b0b68f1b | |||
| 2a61433e6c | |||
| 60f858ab99 | |||
| 1adfa878d7 | |||
| 8db45e478b | |||
| 702d3aae71 | |||
| b11b202271 |
+42
-31
@@ -1,7 +1,7 @@
|
||||
# Architecture
|
||||
|
||||
## Overview
|
||||
Two-milestone build (owner-directed 2026-09-27). Milestone v0.1 builds the UI/UX prototype as the production Next.js frontend with all data access behind a typed `ApiClient` interface backed by `MockApiClient` — no backend, zero throwaway work. Milestone v0.2 (gated on prototype approval) adds the FastAPI backend, the custom async research engine, and the adapter layer, then swaps `MockApiClient` for the OpenAPI-generated client. The engine is a hand-rolled asyncio state machine; every run persists its resolved 12-dial configuration and full event trace for reproducibility and auditability.
|
||||
Three-milestone build (owner-directed). Milestone v0.1 (COMPLETE) built the UI/UX prototype as the production Next.js frontend behind a typed `ApiClient`/`MockApiClient` boundary. Milestone v0.2 (COMPLETE) added the FastAPI backend, custom async research engine, adapter layer, and trust loop, then swapped MockApiClient for the OpenAPI-generated client. Milestone v0.3 — **Hosted** — converts the product to a hosted multi-user service: auth + server-side sessions, per-user encrypted model keys, user-scoped storage and surfaces, and a two-service deployment behind an SSE-safe reverse proxy. The engine keeps its hand-rolled asyncio state machine; every run persists resolved dials, full trace, and structured report/evidence artifacts for reproducibility and auditability.
|
||||
|
||||
## Components
|
||||
|
||||
@@ -13,61 +13,72 @@ Typed pydantic schema for the 12 dials (source universe, search routing, query s
|
||||
|
||||
### 2. Adapter Layer (`src/researchx/adapters/`)
|
||||
Five pluggable slots with versioned contracts:
|
||||
- **Model**: any OpenAI-compatible endpoint (covers Ollama cloud/local and vLLM); separate planner/writer roles; capability probe on connect (context window estimate, conservative request cap = min(model claim, 8192), embedding batch 8) surfaced as UI overrides; structured-output capability warning. Default: ollama.com/v1 cloud-first with localhost:11434/v1 fallback.
|
||||
- **Model**: any OpenAI-compatible endpoint (covers Ollama cloud/local and vLLM); separate planner/writer roles; capability probe on connect (context window estimate, conservative request cap = min(model claim, 8192), embedding batch 8) surfaced as UI overrides; structured-output capability warning. **v0.3:** adapters are constructed **per run** by the Model Adapter Factory (`model_factory.py`) — no shared instance; the per-instance fallback (cloud → localhost) preserves REQ-08 semantics without cross-run mutation; optional `api_key` constructor param carries the resolved per-user credential.
|
||||
- **Search**: Tavily default; DuckDuckGo keyless fallback; interface ready for Exa/Perplexity later.
|
||||
- **Storage**: local filesystem adapter; reports as plain Markdown/JSON under user-chosen directory; run state as JSON after each transition.
|
||||
- **Memory**: off in v1.0 — interface stubbed for semantic caching.
|
||||
- **Secrets**: env-file provider in v1.0 — interface stubbed for OpenBao.
|
||||
- **Storage**: local filesystem adapter; reports as plain Markdown/JSON under a user-chosen local directory; run state as JSON after each transition. **v0.3:** owner-scoped `LocalStorageAdapter(base_dir, owner_id)` instances — run layout `runs/<user_id>/<run_id>/` (supersedes D-010); data root via `RESEARCHX_DATA_DIR` (`researchx.config.paths.data_root()`); protocol gains `append_run_artifact`.
|
||||
- **Memory**: off — interface stubbed for semantic caching.
|
||||
- **Secrets**: env-file provider — interface stubbed for OpenBao. **v0.3:** the scopes map gains a `'keys'` scope (`RESEARCHX_KEK`) consumed by the per-user key store.
|
||||
|
||||
- **Boundaries:** all external I/O lives here; engine never imports vendor SDKs directly.
|
||||
- **DependsOn:** config (dial schemas only)
|
||||
|
||||
### 3. Research Engine (`src/researchx/engine/`)
|
||||
Custom asyncio state machine: PLANNING → AWAITING_APPROVAL → SEARCHING → EXTRACTING → REFLECTING → (depth loop) → SYNTHESIZING → VERIFYING → DONE. One RunActor per run; durable JSON state persisted to storage after each transition (pause/cancel/resume); typed event bus feeding SSE live trace; plan (sub-questions + source universe + outline) halts at AWAITING_APPROVAL until approved/edited.
|
||||
### 3. Identity & Sessions (`src/researchx/auth/`) — NEW v0.3
|
||||
User model (8-char slug `user_id`, email login, argon2id password hashing via argon2-cffi, all hashing off the event loop), server-side session store (opaque `token_urlsafe(32)` httpOnly SameSite=Lax cookies; in-process dict + durable `sessions.json` at the data root; 14-day sliding expiry; immediate revocation on logout/deactivation; token rotation at login), auth guard as a router-level dependency (`Depends(require_user)`) covering all `/api/*` except login/register, CSRF check (custom header on non-GET), first-user-is-admin + admin-managed accounts (`/api/admin/users`), user store `users.json` at the data root.
|
||||
|
||||
- **Boundaries:** owns user/session data + password hashing; no run knowledge.
|
||||
- **DependsOn:** adapters (secrets for the KEK scope), storage (data-root persistence)
|
||||
|
||||
### 4. Per-User Key Store (`src/researchx/auth/key_store.py`) — NEW v0.3
|
||||
AES-256-GCM envelope keyed by `RESEARCHX_KEK` (env-file 'keys' scope, 32 bytes b64/hex), `user_id` as AAD (ciphertext is bound to its owner — swapped files fail `InvalidTag`), `os.urandom(12)` nonce per write, `keys/<user_id>.json` (v1/entries format, atomic rewrite, 0600). API surface is masked-only (`configured` + last-4). Without a configured KEK the store is disabled with honest status; the resolution chain falls to server/keyless.
|
||||
|
||||
- **Boundaries:** owns key encryption/decryption; never returns key material.
|
||||
- **DependsOn:** Identity, adapters/secrets
|
||||
|
||||
### 5. Model Adapter Factory (`src/researchx/adapters/model_factory.py`) — NEW v0.3
|
||||
Per-run credential resolution (REQ-74): user's own key → server env-file key → keyless localhost:11434; constructs one model adapter per run and returns the resolved tier (`user|server|keyless`); the capability probe runs against the resolved tier; `GET /api/config` resolves the factory for the current user without creating a run (fingerprint-cached probe). Fixes the v0.2 shared-adapter fallback-mutation bug by construction.
|
||||
|
||||
- **Boundaries:** resolves credentials and constructs adapters; no engine knowledge.
|
||||
- **DependsOn:** Identity (user), Key Store, adapters (model, secrets)
|
||||
|
||||
### 6. Research Engine (`src/researchx/engine/`)
|
||||
Custom asyncio state machine: PLANNING → AWAITING_APPROVAL → SEARCHING → EXTRACTING → REFLECTING → (depth loop) → SYNTHESIZING → VERIFYING → DONE. One RunActor per run; durable JSON state persisted to storage after each transition (pause/cancel/resume); typed event bus feeding SSE live trace; plan (sub-questions + source universe + outline) halts at AWAITING_APPROVAL until approved/edited. **v0.3:** `RunState` carries `owner_id`; at DONE the engine persists two additional artifacts — `report.json` (structured sections + anchored citations with quotes) and `evidence.json` (passage texts) — written in the verify phase before the evidence store is discarded; citation markers are normalized `[p-N]` → `[^pN]` at the report.json boundary (report.md keeps the trust-loop form); startup sweep marks in-flight runs CANCELLED on process restart (rehydration out of v0.3 scope).
|
||||
|
||||
- **Boundaries:** orchestrates; contains no HTTP handling and no vendor I/O.
|
||||
- **DependsOn:** adapters, methodologies, trust, evidence
|
||||
|
||||
### 4. Evidence Store (`src/researchx/evidence/`)
|
||||
Visited-page store with stable passage IDs; passage extraction from fetched pages. The only source of writer context — the visited-only citation constraint is enforced by construction: the writer sees nothing else.
|
||||
### 7. Evidence Store (`src/researchx/evidence/`)
|
||||
Visited-page store with stable passage IDs; passage extraction from fetched pages. The only source of writer context — the visited-only citation constraint is enforced by construction: the writer sees nothing else. **v0.3:** serialized to `evidence.json` at DONE (passage texts survive the actor).
|
||||
|
||||
- **Boundaries:** owns evidence data model.
|
||||
- **DependsOn:** adapters (search/storage only)
|
||||
|
||||
### 5. Trust Loop (`src/researchx/trust/`)
|
||||
### 8. Trust Loop (`src/researchx/trust/`)
|
||||
Runs inside every methodology: generation-time visited-only constraint [E1]; post-run URL liveness pass with live/stale/dead classification [E1]; claim-to-passage quote anchoring (fuzzy match against stored passages) [E1b]; citation ledger JSON per run (URL, visited_at, passages, claims supported, health, authority tier); verification checklist export.
|
||||
|
||||
- **Boundaries:** read side of evidence store; verification reports feed the ledger.
|
||||
- **DependsOn:** evidence, adapters (model for verification passes)
|
||||
|
||||
### 6. API Layer (`src/researchx/api/`)
|
||||
FastAPI routes: POST /api/runs, GET /api/methodologies, GET /api/runs/{id}, approve/pause/cancel/resume, GET /api/runs/{id}/events (SSE), GET /api/config. OpenAPI schema generates the typed frontend client.
|
||||
### 9. API Layer (`src/researchx/api/`)
|
||||
FastAPI routes — **v0.3 inventory**: `/api/auth/{register,login,logout,me}` (public router; register open only while the user store is empty), `/api/admin/users*` (admin-guarded), `POST /api/runs`, `GET /api/methodologies`, `GET /api/runs/{id}`, approve/pause/cancel/resume, `GET /api/runs/{id}/events` (SSE), `GET /api/runs/{id}/report` (enriched: markdown + structured report + evidence), `GET /api/runs/{id}/ledger`, `GET /api/config` (read-only, user-contextualized: resolved tier, key-store status, per-user cost counters, probe), `GET/PUT /api/config/settings` (per-user settings), `GET /api/library`, `GET /api/library/connections`, `GET /api/graph`, `GET /api/tasks`, `GET /api/suggestions`, `POST /api/refine/{questions,compose}`, `GET/PUT /api/profile`, and `/healthz` outside `/api` (unauthenticated, for proxy health checks). Read-model routes (library/connections/graph/tasks/suggestions/profile/settings) read disk artifacts only — never the RunRegistry; the registry is owner-keyed `(owner_id, run_id)` and pruned of terminal runs. OpenAPI schema generates the typed frontend client.
|
||||
|
||||
- **Boundaries:** transport only — no business logic.
|
||||
- **DependsOn:** engine, methodologies, config
|
||||
- **DependsOn:** engine, methodologies, config, auth, key store, model factory
|
||||
|
||||
### 7. Frontend (Next.js App Router, TypeScript strict)
|
||||
App shell (root layout, P5): persistent sidebar (primary nav — New research / Library / Settings; adapter connection-status dots as a v2 preview) + top bar (⌘K navigation palette — screens + stored reports; avatar button → user menu with the local single-user profile). Screens: Ask (question + optional context; open-question suggestions below the form — follow-ups from unanswered questions of past runs, click-fills-the-box, REQ-67; opt-in Refine dialogue — inline drawer, scripted clarifying exchange, composed refined question + suggested context, REQ-68), Choose (methodology cards with ETA/cost/why-recommended; dials behind "advanced"), Plan & Run (approve/edit plan, live SSE trace, pause/cancel), Reading Room v0 (rendered report, hover-a-citation → exact quoted passage, ledger panel, Markdown/JSON export), Library (P5: folder-structure report storage — auto-categorized folders, recency/title sorting, report cards into the Reading Room; cross-report Connections panel — shared-sources ⇄ / related-topic → edges; project graph + task inbox as v2-preview panels), Settings (P5: masked API-key status, storage directory, default methodology + resolved 12-dial display, appearance preview, about/version).
|
||||
### 10. Frontend (Next.js App Router, TypeScript strict)
|
||||
App shell (persistent sidebar + top bar with ⌘K slot + avatar). **v0.3 additions:** login screen; session-aware shell (avatar menu → real profile, logout); live binding of ALL surfaces (the G-002 mock-delegation boundary is lifted — `GeneratedApiClient` becomes a standalone `ApiClient` implementation with no `MockApiClient` inheritance); auth + CSRF fetch wrapper (`credentials: "include"`, `X-ResearchX-Request` on mutations); EventSource `withCredentials`; two build profiles (mock dev default; hosted build inlines `NEXT_PUBLIC_RESEARCHX_API_MODE=live` + empty same-origin base URL). Screens: Ask (question + context; suggestions below the form; opt-in refine dialogue — now real planner calls), Choose (methodology cards + dials drawer), Plan & Run (approve/edit, live SSE trace, pause/cancel/resume), Reading Room (structured report with anchored hover citations from report.json/evidence.json, ledger panel, exports), Library (folder structure, report cards, connections), Settings (per-user: masked model key, methodology defaults, model names, display-only storage), profile.
|
||||
|
||||
- **Boundaries:** consumes the typed `ApiClient` interface only — components never import the mock client directly. In v0.1 the interface is bound to `MockApiClient` (fixture data + scripted event replay); in v0.2 phase 3 the binding swaps to the OpenAPI-generated client with no component changes.
|
||||
- **DependsOn:** ApiClient contract (v0.1: mock; v0.2: API layer)
|
||||
- **Boundaries:** consumes the typed `ApiClient` interface only — components never import the mock or generated client directly; the boundary selects the binding by build profile.
|
||||
- **DependsOn:** ApiClient contract
|
||||
|
||||
## Data Flow
|
||||
v0.1 (prototype): Ask (question + context) → Choose (methodology slug + dial overrides) → MockApiClient returns scripted plan → Plan & Run (scripted live trace replay, pause/cancel) → Reading Room (mock report with anchored citations, ledger, real file exports). All flows exercise the exact component paths the MVP will use.
|
||||
v0.3 (hosted): browser → HAProxy (TLS, `/api` + `/healthz` → backend :8000, SSE-safe: no response buffering, no API-backend compression, 3600s timeouts) → FastAPI (session guard resolves user → per-user settings + key store → model factory resolves credential tier user→server→keyless → owner-scoped engine run) → durable artifacts under `runs/<user_id>/<run_id>/` → read-models scan disk → Library/Graph/Tasks/Suggestions render → Reading Room renders report.json with anchored citations (hover quotes from evidence.json) → exports round-trip real data. Engine internals (state machine, evidence, trust loop) unchanged from v0.2.
|
||||
|
||||
v0.2 (MVP): Ask (question + context) → Choose (methodology slug + dial overrides) → POST /api/runs → engine PLANNING (planner model via model adapter, dial bundle resolved) → AWAITING_APPROVAL (plan persisted; SSE events; user approves/edits) → SEARCHING (search adapter, routing per dials) → EXTRACTING (fetch pages → evidence store, stable passage IDs) → REFLECTING (planner model; depth loop per dials) → SYNTHESIZING (writer model; context = evidence passages only, per-claim citation markers) → VERIFYING (liveness pass, quote anchoring, confidence labels) → DONE (report + ledger + checklist persisted to storage; Reading Room renders; export).
|
||||
## Deployment Topology (NEW v0.3)
|
||||
Two containers on the LXC host behind the existing HAProxy/TLS front (research.coreci.dev): `researchx` (Next.js frontend, :3000, live build args) and `researchx-api` (FastAPI backend, :8000 host-published, non-root uid 10001, `uvicorn --workers 1` — mandatory: in-memory registry + session store), `researchx-data` volume at `/var/lib/researchx` (RESEARCHX_DATA_DIR), runtime secrets via `--env-file` only. Rollback pairs `researchx-prev` / `researchx-api-prev`. deploy.sh builds both images at the tag, health-checks `/healthz` (backend) + `/ask` (frontend), and asserts live mode in the smoke test.
|
||||
|
||||
## Build Order
|
||||
Milestone v0.1 (prototype): 1. Frontend scaffold + ApiClient/MockApiClient boundary → 2. Ask + Choose → 3. Plan & Run + Reading Room v0 → 4. v2 mockups + polish + 3-click validation → 5. review + owner approval gate (owner returned gate feedback → extension cycle: 5. app shell + research library + settings + avatar → 6. final review + approval re-presentation (2nd owner gate feedback: 7. ask suggestions + refine dialogue → 8. final re-review + ship)).
|
||||
Milestone v0.1 (prototype — COMPLETE): frontend scaffold → Ask/Choose → Plan & Run/Reading Room → mockups/3-click → owner-gate extensions (app shell, library, settings, avatar; suggestions, refine) → approval.
|
||||
Milestone v0.2 (MVP — COMPLETE): methodology framework → adapters → evidence → trust → engine → API → client swap → adversarial + E2E → approval.
|
||||
Milestone v0.3 (hosted): 1. Auth + sessions + user scoping (storage partitioning, registry owner-keying, auth suite) → 2. Per-user settings + encrypted key store + model factory (credential chain, probe-per-tier) → 3. Live surfaces (read-models on disk artifacts, report.json/evidence.json + Reading Room fidelity, refine/suggestions real calls, frontend live swaps, mock-delegation ends) → 4. Hosting (backend Dockerfile + production entrypoint, two-service deploy.sh, SSE-safe HAProxy, live-default hosted build) → 5. Final review + owner gate.
|
||||
|
||||
Milestone v0.2 (MVP, gated on v0.1 approval):
|
||||
1. Methodology framework (dial schema + 2 seed methodologies + registry)
|
||||
2. Adapter layer (model/search/storage contracts + local implementations; fake adapters for tests)
|
||||
3. Evidence store (visited pages, passage extraction)
|
||||
4. Trust loop (liveness, anchoring, ledger)
|
||||
5. Engine (state machine, event bus, durable state, pause/resume)
|
||||
6. API layer (routes + SSE)
|
||||
7. Integration (swap MockApiClient → OpenAPI-generated client)
|
||||
8. Adversarial test suite (hallucinated-URL detection, quote-mismatch detection, pause/resume mid-depth-loop) + E2E happy path
|
||||
|
||||
Build order is dependency-driven: trust verification requires evidence; engine requires adapters + trust; API requires engine; integration requires API; frontend components carry over unchanged from v0.1.
|
||||
Build order is dependency-driven: auth precedes key store (identity); key store precedes the factory (credential chain); read-models precede hosting (disk-backed reads must exist before redeploys); hosting precedes the gate (the owner reviews the deployed thing).
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"phase": 2,
|
||||
"stage": "complete",
|
||||
"milestone": "v0.3",
|
||||
"phase_role": "execution",
|
||||
"attempts": 0,
|
||||
"updated_at": "2026-09-29T22:15:00Z"
|
||||
}
|
||||
@@ -0,0 +1,432 @@
|
||||
# CIAgent Grill Report
|
||||
|
||||
## Run: 2026-09-27 11:25 (mode: mechanical, focus: all)
|
||||
|
||||
### Verdict: Proceed (confidence: 0.72)
|
||||
|
||||
The plan is sound with fixable gaps. The evidence base is unusually strong for a project at P0: a shipped and owner-approved v0.1 (9 tags, decision commit 3fd0e10), a locked two-milestone structure, a typed frontend boundary already in the tree (client.ts), a research file with concrete API shapes, and honest `---ci---` reporting throughout. But the grill found **two internal contradictions and one measurability hole** that would each have surfaced at P3 as rework: (1) PLAN.md P1 claims REQ-07/REQ-12 delivery while ROADMAP/REQUIREMENTS/PLAN's own frontmatter map them to P2; (2) the approved prototype's ApiClient documents seven v0.2 endpoint mappings (`GET /api/graph`, `GET /api/tasks`, `GET /api/library`, `GET /api/library/connections`, `GET /api/suggestions`, `POST /api/refine/*`, `PUT /api/config`) that REQ-25 does not include — a hidden-scope grenade wired to the P3 swap; (3) REQ-23's ≥99% liveness bar cannot be proven in the zero-key test suite it is scheduled into. All three are absorbed below as binding decisions. The MVP does not overreach as specced — REQ-01..34 is a genuine core loop — but the *implicit* live-mode expectation (whole prototype UI working against the backend) does overreach, and is explicitly reduced to the core research loop (G-002, escalated for owner ratification at the P5 gate).
|
||||
|
||||
---
|
||||
|
||||
### Axis 1 — Business Case
|
||||
- **Q1**: What problem does this actually solve, and is that problem still the top priority?
|
||||
- Evidence: PROJECT.md Core Value + Key Decisions ("deep-research agents hallucinate URLs at the highest measured rates; verification cuts failures to <1% [E1]"); owner approved v0.1.8 and immediately unblocked v0.2 (commits 3fd0e10, 42435bc).
|
||||
- Answer: Real, named problem — citation-untrustworthy deep-research agents — converted into a differentiated product thesis (methodology-first + trust loop). The owner's same-day unblocking of v0.2 shows it remains the top priority. Not a project that outlived its justification: the value is entirely unrealized until the MVP ships, which argues for proceeding, not stopping.
|
||||
- Confidence: 0.80
|
||||
- **Q2**: Who is the named executive sponsor, and when did they last make a decision under pressure?
|
||||
- Evidence: Owner decisions under pressure recorded twice in v0.1: P4 gate returned *feedback instead of approval* → binding P5/P6 extension (ROADMAP P4 note, commit 7db53d62); P6 gate returned *2nd feedback* → P7/P8 extension (commit 57c5231, ROADMAP P6 note); final approval 3fd0e10. D-018/D-019 record owner directives for this run.
|
||||
- Answer: The sponsor is pressure-tested, not a rubber stamp — two rejections/extensions before approval. This is the strongest possible sponsor signal available in the record.
|
||||
- Confidence: 0.85
|
||||
- **Q3**: What happens to the business if the project is cancelled?
|
||||
- Evidence: v0.1 is mock-only by design (REQ-53: "no backend, no API keys"); zero research value has shipped; all value lands in v0.2 (REQ-01..34).
|
||||
- Answer: Cancelling now strands 100% of sunk effort — the prototype is a shell without the MVP. The project is not a zombie; it is the opposite: a project whose entire value is still ahead of it, which raises the stakes of P2/P3 execution quality, not the case for stopping.
|
||||
- Confidence: 0.75
|
||||
- **Q4**: Is the ROI calculated against a counterfactual?
|
||||
- Evidence: MISSING — no ROI/counterfactual calculation exists anywhere in .ciagent/.
|
||||
- Answer: No formal ROI. The implicit counterfactual is "ad-hoc LLM prompting with unverified citations," which is plausible but never quantified. For an owner-directed, single-user, local-first tool this is a tolerable omission — the sponsor is the beneficiary — but it means priority calls rest entirely on owner judgment, with no objective measure if enthusiasm fades.
|
||||
- Confidence: 0.55
|
||||
- Decision: G-007 — make the counterfactual *measurable in-product*: per-run request/token/cost counters in the run trace, surfaced via `GET /api/config` (advisory, no hard spend cap in MVP). This is the cheapest honest ROI instrumentation available.
|
||||
|
||||
### Axis 2 — Scope and Requirements
|
||||
- **Q1**: Is the scope expanding, contracting, or genuinely stable?
|
||||
- Evidence: REQ-01..34 locked with traceability (REQUIREMENTS.md table); D-007..D-020 clarify commit recorded (e79102b); v0.1 scope *grew twice* via owner gate feedback (P5/P7 extensions) — proof the gate can grow scope.
|
||||
- Answer: The formal scope is stable and frozen. But the grill found the *informal* scope is ambiguous: the prototype's approved UI implies backend endpoints that REQ-25 does not promise (see Q4). That ambiguity will resolve itself at P3 — either as scope growth or as a visible degraded mode. It must be resolved now, in writing.
|
||||
- Confidence: 0.60
|
||||
- **Q2**: Who owns the requirements, and have they been frozen?
|
||||
- Evidence: Owner-approved milestone structure (PROJECT.md Constraints); REQUIREMENTS.md traceability with per-phase status; clarify decisions D-007..D-020 at confidence 0.75–1.0.
|
||||
- Answer: Owner owns; the pipeline freezes and traces them. Requirements management is this project's strongest discipline.
|
||||
- Confidence: 0.85
|
||||
- **Q3**: What is explicitly out of scope?
|
||||
- Evidence: REQUIREMENTS.md "Out of Scope" table (paywalled sources, chatbot/hosting, auth/multi-tenancy, native Ollama adapter, rename, autonomous v1.0.0); PROJECT.md Out of Scope.
|
||||
- Answer: Explicit, reasoned, and enforced (e.g., OpenAI-compatible-only is restated in constraints and architecture).
|
||||
- Confidence: 0.90
|
||||
- **Q4**: Are there hidden requirements only disclosed late in delivery?
|
||||
- Evidence: **FOUND — two.** (a) `frontend/src/lib/api/client.ts` lines 123–179 document v0.2 mappings to `GET /api/graph`, `GET /api/tasks`, `GET /api/library`, `GET /api/library/connections`, `GET /api/suggestions`, `POST /api/refine/questions`, `POST /api/refine/compose`, `PUT /api/config` — none of which appear in REQ-25 (runs, methodologies, lifecycle, SSE, config-read only) or any v0.2 phase mapping. (b) The same file exposes no `pauseRun`/`resumeRun` while REQ-25 mandates pause/resume in the API and REQ-15 mandates server-side pause/resume across restarts; line 69's comment claims "pause/resume stay client-side concerns," which contradicts the engine requirement.
|
||||
- Answer: The P3 swap ("behind the unchanged ApiClient interface," REQ-26/REQ-54) is wired to detonate: either the backend grows 7+ undocumented endpoints (scope expansion inside the MVP milestone), or live mode silently breaks six approved UI surfaces, or the interface changes anyway (breaking REQ-54's zero-rewrite promise without a plan). This is the single largest finding of this grill.
|
||||
- Confidence: 0.50
|
||||
- Decision: G-002 — declare the v0.2 live-mode surface = **core research loop only** (Ask, Choose, Plan & Run, Reading Room + read-only `GET /api/config`); Library, Settings persistence, Suggestions, Refine, project graph, task inbox, and user profile stay mock-bound behind ApiClient in live mode, honestly marked in-UI, with their backends explicitly deferred to v0.3+. G-003 — add `pauseRun`/`resumeRun` to ApiClient in P3 as the single planned, documented interface extension (mock gains matching implementations), superseding the v0.1 client-side-pause comment; REQ-54's zero-rewrite promise is amended to "no *unplanned* rewrites."
|
||||
- Escalation: [G-009] — owner must ratify the live-mode boundary at the P5 gate (see Escalations).
|
||||
|
||||
### Axis 3 — Architecture and Technical Feasibility
|
||||
- **Q1**: Has the architecture been validated by the people who will build and operate it?
|
||||
- Evidence: RESEARCH-v02.md — 8 topics with concrete API payload shapes, pitfalls, and 14 conclusions written for the builders (pydantic event unions, shielded state writes, `os.replace` atomicity, pytest-asyncio modes, hatchling `packages` key). But `backend/` does not exist yet — zero lines of the architecture have run.
|
||||
- Answer: Design-side validation is strong (this is what a builder-facing research doc should look like); runtime validation is zero, by definition of P0. The risk concentrates at first contact: ollama.com/v1 shapes, Tavily payload, SSE backpressure. P1 Wave 3 is the first live proof.
|
||||
- Confidence: 0.65
|
||||
- **Q2**: What is the integration surface, and where does it quietly double in cost?
|
||||
- Evidence: Five adapter slots (ARCHITECTURE §2); SSE transport (D-014); OpenAPI client generation (D-013, RESEARCH §7 — flags operation-id stability and regen-from-snapshot discipline as pitfalls); the ApiClient surface gap (Axis 2 Q4).
|
||||
- Answer: 5 adapters + SSE + codegen swap = 7 integration seams. The cost-doubling candidate is the P3 swap (seam #7) — precisely where the undocumented-endpoint problem sits. Second candidate: the openapi.json snapshot discipline exists only as a research *pitfall note*, not a P3 must-have.
|
||||
- Confidence: 0.60
|
||||
- Decision: G-005 — P3 must-have: commit the backend `openapi.json` snapshot; regenerate `frontend/src/lib/api/generated/` only from the snapshot (never a live server); add a schema-drift check to the gates; fix FastAPI operation-ids at route creation.
|
||||
- **Q3**: Is there an existing system being replaced — actual data volume, transaction rate, edge-case behavior?
|
||||
- Evidence: Greenfield backend; the "replaced system" is `MockApiClient` (fixtures + scripted replay, frontend/src/lib/api/mock/). REQ-54 defines the swap contract; PERSONAS.md binds the frontend-engineer to it.
|
||||
- Answer: No data migration risk. The migration is one interface binding line (client.ts:187) — provided G-002/G-003 are absorbed; otherwise the "small migration" is the biggest one in the project.
|
||||
- Confidence: 0.70
|
||||
- **Q4**: What technical debt is inherited, and is it budgeted for?
|
||||
- Evidence: (a) the v0.1 "client-side pause" comment (client.ts:69) contradicting REQ-15 — budgeted via G-003. (b) DDG `html.duckduckgo.com/html` scrape as the keyless fallback (D-012, confidence 0.75) — RESEARCH §3 itself warns "rate-limits aggressively... best-effort." (c) Scripted-replay SSE shape must match D-014's event union — verified aligned (D-014 rationale cites the MockApiClient replay contract).
|
||||
- Answer: The DDG fallback is the fragility hotspot: a legacy HTML surface, conservatively scraped, that the research file already expects to fail. The zero-config promise of the product rests partly on it. Mitigation exists in design (surfaced warning, never hang) but is not yet test-enforced.
|
||||
- Confidence: 0.55
|
||||
- Decision: G-008 — DDG fallback is tested against recorded HTML fixtures only (never live-scraped in CI); the degradation contract (partial/empty results + typed warning, never hang, never block the engine) is asserted by test. DDG is documented as degraded-mode, not a reliable second source.
|
||||
|
||||
### Axis 4 — People, Skills, and Organization
|
||||
- **Q1**: Which 2–3 people, if they left, would the project fail?
|
||||
- Evidence: The entire delivery capability is one agent pipeline (coreci) + one owner (decision commit 3fd0e10; all directives owner-recorded). PERSONAS.md is a persona roster, not a human team.
|
||||
- Answer: Key-person dependency is total and structural: owner disengagement stalls every gate by design (that is the governance model); pipeline failure stops all delivery. For a single-user, owner-directed tool this is acceptable — but it means timeline estimates are really "pipeline availability" estimates, and there is no bench.
|
||||
- Confidence: 0.60
|
||||
- **Q2**: Are resources actually allocated at the percentages claimed?
|
||||
- Evidence: Autonomy level "full" (config.json); v0.1 shipped 9 phases, PRs #1–#8, tags v0.1.0–v0.1.8 within ~10 hours of wall clock (commits dd2538f 00:44 → d6ddcfe 10:03, 2026-09-27).
|
||||
- Answer: The pipeline is demonstrably 100% allocated and not fighting BAU fires — velocity is exceptional and verified by artifact trail, not status reports.
|
||||
- Confidence: 0.85
|
||||
- **Q3**: Is there a product owner with actual authority to prioritize?
|
||||
- Evidence: Owner directives recorded as binding decisions (D-018, D-019 at confidence 1.0); two gate rejections prove authority in practice.
|
||||
- Answer: Unambiguous single owner; no committee. Model is working.
|
||||
- Confidence: 0.90
|
||||
- **Q4**: Is the team building capability it doesn't have, hoping to learn as they go?
|
||||
- Evidence: The stack is being built in-milestone: FastAPI, asyncio durability (shielded writes, CancelledError discipline — RESEARCH §5), sse-starlette, httpx adapters, openapi codegen. No backend code exists yet (repo is frontend-only today). RESEARCH-v02.md documents the failure modes in advance (pytest-asyncio event_loop removal, hatchling src-layout wheel pitfall, uvicorn single-worker constraint).
|
||||
- Answer: Yes, capability is being built on the fly — acceptable here because (a) this is a prototype-to-MVP transition with research-backed patterns, and (b) zero-key fakes (REQ-31) mean the engine can be proven without live services. But P2 is where "learning asyncio under load" meets "13 requirements" — see Axis 5.
|
||||
- Confidence: 0.60
|
||||
|
||||
### Axis 5 — Timeline and Estimates
|
||||
- **Q1**: Was the deadline set before or after scope and approach were understood?
|
||||
- Evidence: No wall-clock deadlines exist anywhere in ROADMAP v0.2 — sequencing is dependency-driven (P0→P5), cadence is owner gates (D-018: halt at the owner approval gate).
|
||||
- Answer: No reverse-engineered deadline — the healthiest possible pattern. The only "date" is the owner's attention, which v0.1 proved is present.
|
||||
- Confidence: 0.85
|
||||
- **Q2**: What is the critical path, and what single thing would push it by 3+ months?
|
||||
- Evidence: Critical path P1 → P2 → P3 → P4 (sequential phases, ROADMAP). P2 is the milestone's heaviest phase: 13 requirements (REQ-07, 12, 13–24) including the durable asyncio state machine, trust loop, and live probing. The 3-month pusher is a P3 integration-drift discovery: the ApiClient surface gap (Axis 2 Q4) hitting mid-swap, after P2's engine is built against the wrong boundary assumptions.
|
||||
- Answer: Critical path is P2-heavy and P3-fragile. G-002/G-003 defuse the P3 risk; P2's size needs a pressure valve.
|
||||
- Confidence: 0.70
|
||||
- Decision: G-006 — permit P2 to ship as two patches (P2a: engine + durable state + SSE; P2b: trust loop + live probe/REQ-07/12) or run mid-phase wave gates, without changing milestone scope; the P3 swap plan (endpoint list per G-002) must be drafted at P2 *start*, not P3 start, so drift surfaces before the engine is frozen.
|
||||
- **Q3**: Are the estimates evidence-based?
|
||||
- Evidence: The only velocity base is v0.1: ~1 phase/hour sustained across 9 phases including review+audit each. v0.2 phases are materially heavier (greenfield backend, adversarial tests). MISSING: any per-phase sizing in ROADMAP/PLAN.
|
||||
- Answer: No estimates exist to be wrong — but also none to be right. v0.1 velocity does not transfer 1:1 (frontend screens vs. a durable async engine); treating it as a base rate would understate P2. The mitigation is the phase-patch mechanism (G-006) rather than fake numbers, which this grill endorses over invented estimates.
|
||||
- Confidence: 0.60
|
||||
- **Q4**: Is there a working definition of done?
|
||||
- Evidence: Per-phase success criteria (ROADMAP); PLAN.md gates + "REQ coverage assert: each REQ mapped to ≥1 test or contract proof"; verify stage precedent (PASS-WITH-FIXES with in-phase P0 fixes, PRs #4/#6/#8); final owner gate.
|
||||
- Answer: Yes, and it is enforced — v0.1 reviews fixed 5 P0s and multiple P1s *in-phase*, which is proof the DoD has teeth.
|
||||
- Confidence: 0.85
|
||||
- Decision: G-001 — resolve a DoD contradiction inside P1's own plan: PLAN.md must_haves and tasks T-1.10/T-1.11 claim REQ-07/REQ-12 delivery ("GET /models + structured-output smoke test" = live probing) while PLAN's frontmatter (`requirements: [REQ-01..06, 08..11, 31]`), ROADMAP P1 ("REQ-07, REQ-12 ship in P2... live probing against real endpoints in P2"), and REQUIREMENTS traceability all map them to P2. Binding: P1 ships the typed ProbeResult + structured-output warning *contracts* proven with fake adapters; live endpoint probing executes in P2 with the engine. Secondary bookkeeping fix in the same decision: the "REQ-13..24" range notation includes REQ-17 in both P2 and P4 rows — REQ-17 (3-click on real stack) stays in P4; ranges are corrected to exclude it.
|
||||
|
||||
### Axis 6 — Budget and Financial Realism
|
||||
- **Q1**: What percentage of budget is spent vs. remaining?
|
||||
- Evidence: MISSING — no cost ledger exists. Known spends: pipeline tokens (v0.1: 9 phases of agent execution; v0.2 P0 stages), Ollama cloud key (config.json secrets scope `model`), Tavily key (scope `search`), self-hosted LXC infra (DEPLOY-RECORD.md).
|
||||
- Answer: Untracked. For a single-user local-first tool the absolute numbers are small, but "untracked" is still a finding: no one can currently answer what a Deep Report run costs.
|
||||
- Confidence: 0.60
|
||||
- **Q2**: Are there predictable cost drivers not in the original budget?
|
||||
- Evidence: Deep Report = depth 3/breadth 2 → per run: ~dozens of model calls (planner + depth-loop reflections + writer + verification passes) plus search calls; Ollama cloud is pay-per-use; Tavily free tier is quota-capped; DDG fallback is free-but-fragile (D-012). Localhost:11434 fallback is the zero-cost escape hatch (REQ-08).
|
||||
- Answer: Cost drivers are real and unbounded per run (no request caps on loops beyond dial defaults). The local-fallback design keeps the project from *hard* dependency on spend, but there is no visibility.
|
||||
- Confidence: 0.60
|
||||
- Decision: G-007 — per-run counters (model requests, tokens, search calls) recorded in the run trace and surfaced via `GET /api/config`; advisory only in MVP.
|
||||
- **Q3**: Burn rate and runway?
|
||||
- Evidence: MISSING formal tracking; mitigated by local-first defaults (keyless localhost fallback, DDG keyless fallback) meaning the runway floor is "zero marginal cost, degraded mode."
|
||||
- Answer: The architecture is honest about this: the product's default posture degrades to free. Runway is effectively unbounded if the owner accepts degraded mode; the grill accepts this as sound for the MVP.
|
||||
- Confidence: 0.70
|
||||
- **Q4**: Is the budget contingent on something that hasn't happened yet?
|
||||
- Evidence: No — no sale/board/hiring dependency. Keys exist (config.json scopes), infra exists (LXC), pipeline is running.
|
||||
- Answer: Not contingent. Proceed.
|
||||
- Confidence: 0.80
|
||||
|
||||
### Axis 7 — Risks, Assumptions, and Dependencies
|
||||
- **Q1**: Top 3 assumptions, and evidence for each?
|
||||
- Evidence: (a) *Adapter endpoint compatibility* — ollama.com/v1 OpenAI-shape and Tavily POST payload researched with concrete shapes (RESEARCH §2/§3), but unvalidated live in-repo; P1 Wave 3 is the test. (b) *DDG fallback stability* — RESEARCH §3 itself rates it "best-effort, aggressively rate-limited" (weakest assumption in the plan; D-012 confidence 0.75). (c) *Zero-rewrite client swap* — typed boundary exists (client.ts), RESEARCHX_API_MODE designed (D-020), but the interface gaps (Axis 2 Q4) undercut it until G-002/G-003 land.
|
||||
- Answer: Two of three are well-evidenced; the DDG assumption is the one the project's own research argues against relying on. All three have named first-contact phases (P1 Wave 3, P2, P3).
|
||||
- Confidence: 0.60
|
||||
- Decision: G-008 (fixture-tested DDG degradation contract) — see Axis 3 Q4.
|
||||
- **Q2**: External dependencies outside the team?
|
||||
- Evidence: Tavily quota; Ollama cloud key validity; DDG uncontrolled HTML surface; none of these are under the project's control; all have fallbacks *except* Tavily→DDG quality.
|
||||
- Answer: Dependencies are acknowledged with degradation paths — the correct posture. The unmitigated residual is *degraded search quality* when Tavily is exhausted, which directly attacks the ≥99% liveness bar.
|
||||
- Confidence: 0.65
|
||||
- **Q3**: The single risk that kills the project, and is there a mitigation?
|
||||
- Evidence: P3 swap discovering the ApiClient/surface drift after the engine is frozen (evidence chain: client.ts:123–179 vs REQ-25; ROADMAP P3 "the ApiClient interface absorbs any prototype-era API drift" — an assertion, not a plan).
|
||||
- Answer: Kill risk = integration drift at P3 → rework spiral through a hand-rolled engine → owner patience (already tested twice by extensions) exhausts. Mitigation now exists in writing: G-002 (boundary declared), G-003 (interface change planned), G-005 (snapshot discipline), G-006 (P3 plan drafted at P2 start). This grill's core job is done if those four hold.
|
||||
- Confidence: 0.65
|
||||
- **Q4**: Has a pre-mortem been done?
|
||||
- Evidence: MISSING — no pre-mortem artifact. This grill is the closest proxy.
|
||||
- Answer: "It's 12 months out and v0.2 failed" — the derivable causes, in order: (1) P3 surface gap hit late (mitigated: G-002/003/005/006); (2) P2 asyncio durability bugs (pause/resume across restarts is genuinely hard — mitigated by RESEARCH §5 patterns + REQ-32 pause-mid-depth-loop adversarial tests); (3) quote anchoring thrash on real noisy pages (difflib 0.85 is fixture-proven only — live-page behavior unknown until P4/live runs); (4) DDG dead + Tavily exhausted → un-demoable zero-config mode (mitigated: G-008 + owner-held Tavily key).
|
||||
- Confidence: 0.60
|
||||
|
||||
### Axis 8 — Governance, Decision-Making, and Communication
|
||||
- **Q1**: Who is the decision-maker when two parties disagree?
|
||||
- Evidence: Single owner; all decisions recorded as decision commits (3fd0e10; D-007..D-020 in e79102b); escalation hooks codified in config.json (deploy, delete_data, merge_to_main, prototype_approval).
|
||||
- Answer: Unambiguous. No dual-executive stall is possible in this structure.
|
||||
- Confidence: 0.90
|
||||
- **Q2**: Governance cadence and escalation pattern — useful in a crisis?
|
||||
- Evidence: Per-phase ship + verify + audit with `---ci---` blocks; v0.1 precedent: pipeline *halted itself* at gates twice (7db53d62, 57c5231) rather than self-approving — the escalation pattern is proven under the exact stress it exists for.
|
||||
- Answer: Cadence is per-phase and gates have been exercised under pressure. Crisis-tested by construction.
|
||||
- Confidence: 0.85
|
||||
- **Q3**: What is being omitted from status reports?
|
||||
- Evidence: Honest reporting verified in history: "PASS-WITH-FIXES," "PASS-WITH-NOTES," "approval_pending," P0s disclosed and fixed in-phase; DEPLOY-RECORD.md as a separate ledger. The one systemic omission this grill found lives in *spec comments, not status blocks*: the client.ts v0.2 endpoint mappings that were never promoted into REQUIREMENTS — a silent scope assumption, now surfaced (G-002).
|
||||
- Answer: Status reporting is honest. Spec-side implication drift is the channel where optimism leaked — and it's now on the record.
|
||||
- Confidence: 0.80
|
||||
- **Q4**: Is there a stop-the-project trigger?
|
||||
- Evidence: Owner rejection at gate → binding fix cycle (v0.1 exercised twice); v1.0.0 owner-only (config.json major_locked); D-018 halts at the v0.2 gate; D-019 forbids deploys without directive.
|
||||
- Answer: Yes — and rejection has demonstrably not become politically impossible; the owner said "not yet, extend" twice without ceremony.
|
||||
- Confidence: 0.85
|
||||
|
||||
### Axis 9 — Change, Adoption, and Operational Readiness
|
||||
- **Q1**: Who will use this, how does their work change, and what's in it for them?
|
||||
- Evidence: Single-user owner-researcher (PROJECT.md constraint: "Single-user, local-first"); work change = ad-hoc prompting → structured, reproducible methodology runs with auditable citations (REQ-04 dial snapshots, REQ-21 ledger, REQ-22 checklist).
|
||||
- Answer: The user is the sponsor — adoption politics are minimal, and the value proposition (trustable citations) is the same thesis the sponsor approved twice. The real adoption test is whether live-mode runs *feel* better than the mock — which lands at P4/REQ-17.
|
||||
- Confidence: 0.80
|
||||
- **Q2**: Is ops involved now, or handed a finished product at go-live?
|
||||
- Evidence: The pipeline *is* ops: deploy.sh with rolling swap + `researchx-prev` rollback container (commit 2ffc71c), gates re-run at deploy time (DEPLOY-RECORD.md rows), VERSIONING.md rules. Gap: the deploy system is frontend-only; the MVP's live mode requires a local backend process (uvicorn, single worker — RESEARCH §4) whose serving story at the owner gate is undefined (D-019 forbids deploys without directive, so live-mode *presentation* mode is an open question).
|
||||
- Answer: Ops is built-in and rollback-capable for the frontend. The backend has no deploy story — coherent with local-first, but it means the P5 owner-gate demo of the *live* MVP needs a plan (local backend + keys at demo time, or gate runs on mock mode). This rides with G-009.
|
||||
- Confidence: 0.60
|
||||
- **Q3**: What is the rollback plan?
|
||||
- Evidence: Tags v0.1.0–v0.1.8 preserve history; deploy.sh swaps containers with a `researchx-prev` rollback; durable run state is per-run JSON on local disk (D-017) — engine changes cannot corrupt other runs' state; frontend mock mode (RESEARCHX_API_MODE=mock default, D-020) is itself a rollback for the entire live integration.
|
||||
- Answer: Multi-layer rollback exists and is codified. The mock-default flag is an unusually good one — the MVP can ship with live mode opt-in, making go-live risk nearly zero.
|
||||
- Confidence: 0.80
|
||||
- **Q4**: Have success criteria been validated with the people who will judge success?
|
||||
- Evidence: REQ-17 (≤3 clicks on real stack) is P4-tested, validated on mock in v0.1 (REQ-61 pinned by test). REQ-23 (≥99% citation liveness) — **measurement gap**: the entire adversarial suite is zero-key on a 5–8 page fixture corpus (RESEARCH §5b); 99% liveness on fixture URLs is vacuous, and no keyed/live validation run is scheduled anywhere in P1–P5.
|
||||
- Answer: The 99% bar — the project's headline promise — is currently scheduled to be "verified" in a harness that cannot verify it. If the owner gate treats REQ-23 as CI-green, the status report will be honest-looking but hollow.
|
||||
- Confidence: 0.55
|
||||
- Decision: G-004 — REQ-23 is defined as a **runtime per-run gate** enforced at export (failures listed in the verification checklist, REQ-22) plus an opt-in, keyed, live-URL test tier (skipped by default, run before ships at owner discretion); P4 proves the *gate mechanics* with fixtures and explicitly does not claim live 99% attainment. The MVP definition of done for REQ-23 = the gate exists and enforces, not "CI showed 99%."
|
||||
|
||||
### Meta — Closing Review
|
||||
- **Q1**: If you were the auditor, what would you flag?
|
||||
- Evidence: The two internal contradictions (PLAN vs ROADMAP/REQUIREMENTS on REQ-07/12, PLAN must_haves vs PLAN frontmatter; REQ-17 double-mapped in range notation) and the spec-comment endpoint mappings outside REQ-25 (client.ts:123–179).
|
||||
- Answer: Flag #1: phase-mapping integrity — the traceability table is the single source of truth and PLAN must conform to it (G-001). Flag #2: an approved artifact (client.ts) documents v0.2 API surface that the v0.2 spec does not contain — the grill treats this as the most consequential process lapse found, because it would have surfaced as a P3 "surprise" after the engine was frozen.
|
||||
- Confidence: 0.75
|
||||
- **Q2**: What is the project not doing that it should?
|
||||
- Evidence: Missing items found across axes: openapi snapshot drift check as a gate (G-005), per-run cost counters (G-007), live-URL test tier for REQ-23 (G-004), P3 swap plan at P2 start (G-006), fixture-based DDG tests (G-008), live-mode boundary declaration (G-002).
|
||||
- Answer: All six are now bound. Nothing found suggests the project is doing something it should not.
|
||||
- Confidence: 0.75
|
||||
- **Q3**: What is the simplest version that delivers 80% of the value?
|
||||
- Evidence: The MVP already trimmed to 2 of 8 methodologies (REQ-03) and a core-loop-only API (REQ-25). The remaining 20% of complexity carrying outsized risk: Deep Report's depth-3 breadth-2 loop (multiplies engine, trust, and cost surface) vs. Rapid Answer's depth-1 single-pass core.
|
||||
- Answer: Rapid Answer alone + trust loop = 80% of the thesis proof. Deep Report is the pre-agreed deferral lever if P2 overruns — decided *now*, while it is cheap, rather than mid-crisis.
|
||||
- Confidence: 0.65
|
||||
- Decision: G-010 — contingency lever, pre-agreed: if P2/P4 slip beyond one extension cycle, Deep Report (REQ-03 partial) defers to v0.3 and the MVP ships Rapid Answer end-to-end; the lever requires owner sign-off when pulled, per gate rules.
|
||||
- **Q4**: What must be true for this to succeed in the next 90 days, and is it true today?
|
||||
- Evidence: Required: (a) researched adapter shapes hold live (untested — first proof P1 Wave 3); (b) owner engagement continues (proven: 3 gate decisions in one day); (c) Tavily key active/quota available (held in secrets scope; unverified live); (d) the P3 boundary is as declared in G-002 (currently *not* true in writing — client.ts says otherwise).
|
||||
- Answer: Three of four are true or evidenced; (d) becomes true when the orchestrator promotes G-002/G-003. That promotion is the gating action of this entire grill.
|
||||
- Confidence: 0.70
|
||||
|
||||
---
|
||||
|
||||
### Binding Decisions
|
||||
| ID | Axis | Decision | Confidence |
|
||||
|----|------|----------|-----------|
|
||||
| G-001 | 2/5 | Resolve the REQ-07/REQ-12 phase contradiction in PLAN.md's favor of the ROADMAP/traceability mapping: P1 ships typed probe/warning contracts proven with fakes; live endpoint probing (GET /models, structured-output smoke test) executes in P2. Reword PLAN must_haves T-1.10/T-1.11 to contract level; fix the REQ-13..24 range notation so REQ-17 appears only in P4. | 0.85 |
|
||||
| G-002 | 2/3 | Declare v0.2 live-mode surface = core research loop only (Ask, Choose, Plan & Run, Reading Room, read-only GET /api/config). Library, Settings persistence, Suggestions, Refine, project graph, task inbox, user profile stay mock-bound in live mode, honestly marked; their backends are v0.3+ scope. Update client.ts v0.2-mapping comments accordingly. | 0.80 |
|
||||
| G-003 | 3 | Add `pauseRun`/`resumeRun` to the ApiClient interface in P3 as the single planned, documented interface extension (mock gains matching implementations); run-level pause is server-side per REQ-15/REQ-25, superseding the v0.1 "client-side concerns" comment. REQ-54 amended: no *unplanned* rewrites. | 0.80 |
|
||||
| G-004 | 7/9 | REQ-23 (≥99% liveness) is a runtime per-run gate enforced at export and surfaced in the verification checklist — not a zero-key CI assertion. Add an opt-in keyed live-URL test tier (skipped by default). P4 proves gate mechanics with fixtures; MVP DoD for REQ-23 = the gate exists and enforces. | 0.75 |
|
||||
| G-005 | 3/5 | P3 must-have: commit the backend openapi.json snapshot; regenerate frontend/src/lib/api/generated/ only from the snapshot; add a schema-drift check to gates; fix FastAPI operation-ids at route creation. | 0.85 |
|
||||
| G-006 | 5 | Permit P2 (13 REQs — heaviest phase) to ship as two patches (P2a engine+durable state+SSE, P2b trust loop+live probe) or run mid-phase wave gates without milestone scope change; draft the P3 swap endpoint plan (per G-002) at P2 start, not P3 start. | 0.70 |
|
||||
| G-007 | 1/6 | Per-run advisory counters (model requests, tokens, search calls) recorded in the run trace and surfaced via GET /api/config — the cheapest honest ROI/burn instrumentation; no hard spend cap in MVP. | 0.70 |
|
||||
| G-008 | 3/7 | DDG fallback tested against recorded HTML fixtures only (never live-scraped in CI); degradation contract (partial/empty + typed warning, never hang the engine) asserted by test; DDG documented as degraded-mode, not a reliable second source. | 0.70 |
|
||||
| G-010 | Meta | Pre-agreed contingency lever: if P2/P4 slip beyond one extension cycle, Deep Report (depth 3) defers to v0.3 and the MVP ships Rapid Answer end-to-end; pulling the lever requires owner sign-off at a gate. | 0.65 |
|
||||
|
||||
### Escalations
|
||||
- [G-009] Axis 2 — Owner ratification required at the P5 gate (D-018 halt): the v0.2 MVP demonstrates the **live core research loop only**; the remaining approved-prototype surfaces (Library, Settings persistence, Suggestions, Refine, graph/inbox previews, profile) remain mock-bound until v0.3+, and the live-mode gate demo requires a local backend + keys (or runs in mock mode) since D-019 forbids deploys. This redefines what "MVP done" looks like at the demo and must not be discovered by the owner at the gate — it must be ratified in advance. Engineering-side handling is bound in G-002/G-003; the ratification itself is unresolved until the owner records it.
|
||||
- **RESOLVED 2026-09-27: RATIFIED.** Owner approval recorded via /ci-run 'APPROVED' at the v0.2.5 gate (decision commit 4721c2a), following the owner-directed UAT staging at https://research.coreci.dev. Live-mode scope = core research loop only is the accepted MVP boundary; mock-bound surfaces move to v0.3+.
|
||||
# CIAgent Grill Report
|
||||
|
||||
## Run: 2026-09-29 17:05 (mode: mechanical, focus: all)
|
||||
|
||||
### Verdict: Proceed (confidence: 0.68)
|
||||
|
||||
The plan survives adversarial re-verification better than any artifact this project has produced: the grill independently re-checked the research file's load-bearing claims against the tree and found them exact — the shared-adapter fallback mutation (`ollama.py:73-79`), the `config_status` private-registry read (`app.py:174-177`), the CORS-credentials gap (`app.py:143-148`), the fake-serving module global (`app.py:359`), the missing `append_run_artifact` protocol method, the `[p-N]`/`[^pN]` marker split (`actor.py:337` vs `report-body.ts:18`), the export fabrication (`live-client.ts:296-304/335/376`), and the 8-test-file partitioning blast radius. The auth supersession is traceable end to end (a4afccc constraint rewrite + D-101..D-115 @ 33f4fa0 + ROADMAP session note — matching the owner's "one milestone / admin-managed / server-key-fallback" session picks). Engine-contract preservation is a real design discipline, not a slogan. But the grill found **one "verified" research claim that is false** (the restart sweep's CANCELLED-from-any-state legality — `states.py` forbids PLANNING→CANCELLED), **three phase/bookkeeping contradictions** (REQ-75 triple-mapped across three documents; two stale out-of-scope rows contradicting the same-file supersession; C3-19's pitfall-phase mapping vs PLAN), **one unaddressed prototype-fidelity gap** (the task-inbox checkbox cannot survive the derived live read-model), and **an honestly-unbudgeted public attack surface** (no password policy, no login rate limit on a TLS-fronted public URL). All are absorbed as binding decisions G-101..G-110; one item is genuinely owner-required (G-111: P4's definition-of-done depends on a staged host deployment that D-019 forbids without a directive). The milestone is heavier than v0.2 — the P3 pressure valve absent from the plan is bound here (G-108) as the G-006 precedent. Proceed.
|
||||
|
||||
### Axis 1 — Business Case
|
||||
- **Q1**: What problem does this actually solve, and is that problem still the top priority?
|
||||
- Evidence: v0.2.5 approved and merged 2026-09-27 (8db45e4, b11b202); v0.3 scope owner-directed the next day, 2026-09-29 (a4afccc; ROADMAP "owner session 2026-09-29"); DEPLOY-RECORD.md itself records "live mode additionally needs the backend API on the host, which is v0.3+ deployment work" — the deployment gap is acknowledged in the ship record, not invented by the pipeline.
|
||||
- Answer: Real and owner-named: convert the ratified mock-bound surfaces + single-user local loop into a hosted multi-user product with real inference. The same-day sequencing after v0.2.5 approval is the strongest available priority signal.
|
||||
- Confidence: 0.80
|
||||
- **Q2**: Who is the named executive sponsor, and when did they last make a decision under pressure?
|
||||
- Evidence: Owner pressure-tested through v0.1 (two gate rejections → binding extensions, commits 57c5231, 3fd0e10), G-009 ratified at the v0.2 gate (4721c2a); the v0.3 direction itself is a decision under pressure — superseding a recorded constraint ("single-user, local-first") the same day the MVP shipped.
|
||||
- Answer: Sponsor engaged and decisive; the supersession is a recorded constraint change, not scope drift.
|
||||
- Confidence: 0.85
|
||||
- **Q3**: What happens to the business if the project is cancelled?
|
||||
- Evidence: v0.2.5 live mode = core research loop only, mock-default frontend, no hosting (G-009 ratification, ROADMAP v0.2 complete note); the owner-stated v0.3 purpose is the hosted product.
|
||||
- Answer: Cancelling strands the G-009 promise ("mock-bound surfaces move to v0.3+") and leaves the product undeployable in its owner-intended form. Not a zombie — the payoff milestone.
|
||||
- Confidence: 0.75
|
||||
- **Q4**: Is the ROI calculated against a counterfactual?
|
||||
- Evidence: MISSING — no formal ROI anywhere; G-007 counters carried advisory-only, owner-keyed in P2 (T-2.6, P-13). v0.3 adds real costs v0.2 did not carry: public infrastructure exposure and the server-tier key serving any admin-created user's runs.
|
||||
- Answer: Still owner-judgment — tolerable for a sponsor-beneficiary product with an admin-bounded user set, but the server-key cost posture is now a stated-nowhere exposure; bound as G-110.
|
||||
- Confidence: 0.55
|
||||
- Decision: G-110 — document the cost posture: server-tier runs bill the owner's key; the bound is admin-managed accounts; per-user counters surface server-tier usage; hard caps deferred v0.4+.
|
||||
|
||||
### Axis 2 — Scope and Requirements
|
||||
- **Q1**: Is the scope expanding, contracting, or genuinely stable?
|
||||
- Evidence: Scope grew by owner directive (ROADMAP 2026-09-29 note); REQ-69..85 frozen with per-phase traceability; PLAN frontmatter carries all 17. But the G-001-class phase contradictions persisting: REQ-75 is mapped P3 by REQUIREMENTS traceability, P4-only by ROADMAP P3/P4 lists, and split by PLAN (delegation-end in P3, hosted in P4). C3-19 likewise maps P-1/P-2/P-13 to P1 while PLAN closes them in P2.
|
||||
- Answer: Formal scope stable and owner-frozen; the traceability layer is where the drift lives — three documents disagree about one requirement. Bound in G-103.
|
||||
- Confidence: 0.60
|
||||
- Decision: G-103 — bind the REQ-75 split explicitly; fix the traceability row notation; declare PLAN's pitfall-closure mapping authoritative over C3-19's compressed prose.
|
||||
- **Q2**: Who owns the requirements, and have they been frozen?
|
||||
- Evidence: Owner-directed supersession traceable (a4afccc strikethrough + rationale; D-101..D-115 clarify commit 33f4fa0 at confidence 0.75–0.95).
|
||||
- Answer: Owner owns; pipeline freezes and traces. The supersession discipline is exemplary — the constraint change is visible, dated, and strikethrough-preserved rather than silently rewritten.
|
||||
- Confidence: 0.85
|
||||
- **Q3**: What is explicitly out of scope?
|
||||
- Evidence: **CONTRADICTION FOUND** — REQUIREMENTS.md Out-of-Scope table still carries "Auth / multi-tenancy | Single-user local-first MVP"; PROJECT.md Out of Scope still lists "multi-tenant/auth" — while PROJECT.md:34 records the supersession in the same file and REQ-69..71 define the now-in-scope auth. The honest deferrals (OAuth v0.4+, 6 methodologies v0.4+, KEK rotation, account deletion, rehydration) are all recorded.
|
||||
- Answer: Mostly honest, with two stale rows that directly contradict a recorded decision — exactly the spec-comment drift class the v0.2 grill flagged. Bound in G-102.
|
||||
- Confidence: 0.85
|
||||
- Decision: G-102 — update both stale out-of-scope rows to match the supersession.
|
||||
- **Q4**: Are there hidden requirements only disclosed late in delivery?
|
||||
- Evidence: **FOUND — three.** (a) REQ-78 promises "checkable follow-ups"; `library-view.tsx:430-471` ships a working checkbox; the live read-model (D-114) derives status answered-iff-DONE over a GET-only route — a live user's check silently reverts on refetch; PLAN never addresses it. (b) No password minimum length exists anywhere (grep-verified). (c) No login rate limiting anywhere (grep-verified) — argon2 verify is ~50-100ms CPU, a public flood is a DoS amplifier on a single worker.
|
||||
- Answer: The v0.2 G-002 lesson recurs once (the toggle); the exposure items are new-hosting hidden requirements. All three bound (G-109, G-104).
|
||||
- Confidence: 0.75
|
||||
|
||||
### Axis 3 — Architecture and Technical Feasibility
|
||||
- **Q1**: Has the architecture been validated by the people who will build and operate it?
|
||||
- Evidence: RESEARCH-v03 claims re-verified by this grill: 11 of 12 sampled claims exact. **One claim is FALSE**: RESEARCH §7/C3-11 "rewrite every non-terminal state.json as CANCELLED (legal from every non-terminal state, verified states.py)" — `states.py` `_LEGAL_TRANSITIONS[PLANNING] = {AWAITING_APPROVAL, FAILED}`: no CANCELLED. A crash mid-PLANNING leaves a persisted PLANNING state the sweep cannot legally cancel.
|
||||
- Answer: Design-side validation is the strongest yet — but the single false "verified" claim is the precise failure mode mechanical research invites: verification asserted, not re-run. Bound in G-101.
|
||||
- Confidence: 0.85
|
||||
- Decision: G-101 — startup sweep must handle PLANNING-state runs: add PLANNING→CANCELLED to `_LEGAL_TRANSITIONS` or document the sweep as an explicit transition-validation bypass; pinned by test in T-1.8.
|
||||
- **Q2**: What is the integration surface, and where does it quietly double in cost?
|
||||
- Evidence: Five seams: auth dependency threading (P1), per-run factory + probe-per-tier (P2), disk read-models (P3), snapshot regen ×3 (G-005 wired into P1/P2/P3), HAProxy front extension (P4). The cost-doubler candidate is SSE-through-proxy: the only seam whose live configuration is deferred to deploy time.
|
||||
- Answer: Residual risk is bounded, not eliminated: the P4 smoke asserts a full unbuffered SSE run through the proxy path with the `-prev` rollback pair retained — worst case is one deploy iteration, not milestone rework. Accepted with the G-111 governance condition.
|
||||
- Confidence: 0.65
|
||||
- **Q3**: Is there an existing system being replaced?
|
||||
- Evidence: Greenfield hosting; pre-hosted artifacts explicitly disposable (D-106, owner-recorded); the "replaced system" is the mock delegation layer (live-client.ts:61) — deletion is D-112/T-3.8 with component tests proving zero rewrites (T-3.12). Volume model: admin-managed accounts (REQ-70) bound the user set; single worker mandatory.
|
||||
- Answer: No migration risk; the swap is designed as a deletion with regression pins. The one fidelity trap (task-inbox toggle) is found and bound (G-109).
|
||||
- Confidence: 0.75
|
||||
- **Q4**: What is the technical debt being inherited, and is it budgeted for?
|
||||
- Evidence: The 14-item pitfall ledger is budgeted per-phase (PLAN cross-phase notes) — with C3-19's compressed phase mapping contradicting PLAN (folded into G-103). Marker normalization: one-directional at the report.json boundary only; report.md raw form pinned by test; trust-loop regexes untouched.
|
||||
- Answer: If a future writer emits `[^pN]` natively, `_cited_urls` matches nothing → ledger empties → the G-004 export gate fails visibly at runtime, and the raw-form pin catches it in CI first. Silent corruption is not reachable.
|
||||
- Confidence: 0.70
|
||||
|
||||
### Axis 4 — People, Skills, and Organization
|
||||
- **Q1**: Which 2–3 people, if they left, would the project fail?
|
||||
- Evidence: One agent pipeline + one owner; gates owner-halt by design (D-018). The security-engineer persona is phase-specific P1/P2/P4 with an explicit removal note (PERSONAS.md).
|
||||
- Answer: Total key-person dependency remains structural and accepted; the P5 full multi-persona review covers the security persona's absence.
|
||||
- Confidence: 0.60
|
||||
- **Q2**: Are resources actually allocated at the percentages claimed?
|
||||
- Evidence: v0.1 shipped ~9 phases in ~1 day; v0.2 shipped P0..P5 in one day; v0.3 P0 commits same-day (a4afccc→dca86a7).
|
||||
- Answer: Velocity verified by artifact trail. v0.3's four phases are each multi-domain — the evidence supports capacity, with the G-108 valve for the heaviest phase.
|
||||
- Confidence: 0.80
|
||||
- **Q3**: Is there a product owner with actual authority to prioritize?
|
||||
- Evidence: Owner-directed supersession recorded same-day as MVP ship (a4afccc); G-009 ratified at gate (4721c2a); D-018/D-019 carry.
|
||||
- Answer: Unambiguous single owner, exercised.
|
||||
- Confidence: 0.90
|
||||
- **Q4**: Is the team building capability it doesn't have, hoping to learn as they go?
|
||||
- Evidence: argon2-cffi, AESGCM envelope encryption, server-side sessions, SSE-through-proxy are all new to the codebase; mitigations are research-verified concrete shapes + adversarial suites specced + a dedicated security-engineer persona on exactly the phases that carry the new capability.
|
||||
- Answer: Yes — learning-while-doing on a security-critical public surface, the highest-stakes version of this risk. The mitigation structure is proportionate. Acceptable; the G-104 hardening items close the gaps the research did not self-identify.
|
||||
- Confidence: 0.60
|
||||
|
||||
### Axis 5 — Timeline and Estimates
|
||||
- **Q1**: Was the deadline set before or after scope and approach were understood?
|
||||
- Evidence: No wall-clock deadlines anywhere; dependency-driven phases; owner gates as the only cadence.
|
||||
- Answer: Healthiest pattern, unchanged from v0.2.
|
||||
- Confidence: 0.85
|
||||
- **Q2**: What is the critical path, and what single thing would push it by 3+ months?
|
||||
- Evidence: P1→P2→P3→P4 sequential. P3 is the heaviest (7 REQs + standalone client + every surface swap + Reading Room fidelity + delegation-end); P1 is the widest (auth + partitioning across the 10-touchpoint list). The v0.2 pusher-class risk (integration drift at the swap) is pre-defused by G-005 wired into all three route-touching phases + the C3-18 exhaustive interface list.
|
||||
- Answer: The pusher is P3 overrun with no pre-agreed valve — v0.2 had G-006; v0.3's plan had none. Bound in G-108.
|
||||
- Confidence: 0.70
|
||||
- Decision: G-108 — P3 may ship as two patches (P3a backend; P3b frontend) or run mid-phase wave gates, without scope change; tags extend on the v0.3.x line.
|
||||
- **Q3**: Are the estimates evidence-based?
|
||||
- Evidence: v0.1/v0.2 velocity does not transfer 1:1 (auth/crypto/hosting are new domains); no per-phase sizing exists. The honest mitigation remains the phase-patch mechanism — now with the P3 seam named in advance.
|
||||
- Answer: No estimates to be wrong; the valve replaces fake numbers. Endorsed, per the v0.2 precedent.
|
||||
- Confidence: 0.60
|
||||
- **Q4**: Is there a working definition of done?
|
||||
- Evidence: Per-phase must-haves are concretely testable (401 pins, two-user isolation, tamper pins, sweep test, drift gate, four smoke assertions); P5 owner gate. **One DoD term is undefined**: T-4.7/T-4.9 run the smoke "against a staged deploy" — "staged" is never defined, and the host runs one container today.
|
||||
- Answer: DoD has teeth everywhere except the P4 smoke's target. Bound in G-107; its owner-contingency half escalates (G-111).
|
||||
- Confidence: 0.75
|
||||
- Decision: G-107 — "staged deploy" defined: candidate pair built + run on the LXC host, health green, the four smoke assertions executed host-locally against the candidates; public TLS-front cutover remains a discrete owner-directive act (D-019) with post-cutover public assertions run with the -prev pair armed.
|
||||
|
||||
### Axis 6 — Budget and Financial Realism
|
||||
- **Q1**: What percentage of budget is spent vs. remaining?
|
||||
- Evidence: MISSING — no cost ledger (v0.2 finding, carried). New v0.3 spends: second container family, argon2-cffi + cryptography (OSS), HAProxy front extension (existing instance), owner's Ollama key serving server-tier users.
|
||||
- Answer: Untracked, small in absolute terms, tolerable for sponsor-beneficiary scope — but the server-tier exposure is a stated-nowhere cost posture (G-110).
|
||||
- Confidence: 0.60
|
||||
- **Q2**: Are there predictable cost drivers not in the original budget?
|
||||
- Evidence: (a) Server-key tier: any admin-created user's keyless runs bill the owner (REQ-74 chain; G-007 counters advisory-only). (b) Host disk: deploy.sh (212 lines, verified) builds one image today with no free-space assert and no image prune; P4 doubles image families and runs 4 containers.
|
||||
- Answer: Both are real; (a) is a policy posture to state (G-110), (b) is deploy hygiene to add (G-106).
|
||||
- Confidence: 0.70
|
||||
- Decision: G-106 — deploy.sh gains a pre-build free-disk assertion and a post-swap dangling-image prune; the two-image/rollback-pair footprint is documented as the host budget.
|
||||
- **Q3**: Burn rate and runway?
|
||||
- Evidence: Keyless localhost + DDG fallback keep the zero-marginal-cost degraded posture (G-008 carries); per-user keys shift burn to users; refine endpoints ride the same chain with honest 503.
|
||||
- Answer: Runway effectively unbounded in degraded mode; hosting costs are fixed and small. Sound.
|
||||
- Confidence: 0.70
|
||||
- **Q4**: Is the budget contingent on something that hasn't happened yet?
|
||||
- Evidence: Infra exists (LXC + HAProxy already TLS-fronting research.coreci.dev); keys exist in scopes. One new prerequisite: RESEARCHX_KEK does not exist yet (provisioned into `.ciagent/.env.secrets` at P2 — never git).
|
||||
- Answer: Not contingent in any structural sense; the KEK is a trivial owner action — which is precisely why its loss posture must be stated before it becomes load-bearing (G-105).
|
||||
- Confidence: 0.75
|
||||
- Decision: G-105 — KEK-loss posture stated honestly: loss = per-user encrypted keys unrecoverable, chain degrades to server/keyless honestly, users re-enter keys under a re-provisioned KEK; the owner MUST back up the KEK off-repo; rotation tooling stays out of v0.3 (recorded, not silently dropped).
|
||||
|
||||
### Axis 7 — Risks, Assumptions, and Dependencies
|
||||
- **Q1**: Top 3 assumptions, and evidence for each?
|
||||
- Evidence: (a) *HAProxy live-host config accepts the SSE-safe front extension* — version and TLS front evidenced by two prior deploys; global/default sections unread until P4. Medium evidence. (b) *Single-worker in-memory registry/sessions suffice* — bounded by admin-managed accounts; restart sweep + pruning + disk read-models remove restart-fragility. Good evidence. (c) *Zero-component-rewrite surface swaps hold* — evidenced by the v0.2 P3 precedent + G-005 + T-3.12 regression suite; the task-toggle is the one discovered exception (G-109).
|
||||
- Answer: Two well-evidenced; (a) is the residual, bounded by the P4 smoke + rollback pair.
|
||||
- Confidence: 0.60
|
||||
- **Q2**: External dependencies outside the team?
|
||||
- Evidence: HAProxy host state (owner-held, D-019-gated), Ollama cloud/local + Tavily/DDG (carried v0.2, G-008 carries), Docker host disk (G-106 binds the missing hygiene).
|
||||
- Answer: All acknowledged with degradation or rollback paths; the proxy is the only one without a code-level fallback, and it has a process-level one.
|
||||
- Confidence: 0.65
|
||||
- **Q3**: The single risk that kills the project, and is there a mitigation?
|
||||
- Evidence: Authorization-class auth bugs on a public URL (cross-user leakage) — mitigated by construction: owner-keyed registry 404s + partitioned storage + per-request owner-scoped adapters + the two-user isolation test pinned in P1/P3 + the P4 exposure audit + admin-managed accounts bounding the victim set. The sweep-legality bug (G-101) is the found instance of the adjacent class.
|
||||
- Answer: Kill risk is real but the mitigation stack is the plan's strongest suit; the found bug proves the stack needs the grill, and now it has the fix.
|
||||
- Confidence: 0.70
|
||||
- **Q4**: Has a pre-mortem been done?
|
||||
- Evidence: MISSING as a formal artifact; this grill is the proxy.
|
||||
- Answer: "12 months out, v0.3 failed" — derivable causes: (1) the sweep bug corrupting restarts at first redeploy (bound: G-101); (2) P3 fidelity parity slips — one found (toggle, G-109), the rest pinned by parity tests; (3) proxy/SSE iteration thrash post-cutover (bounded: smoke + rollback); (4) KEK lost with no documented posture (bound: G-105); (5) public brute-force/DoS on a fresh auth surface (bound: G-104). All five now bound or bounded-by-design.
|
||||
- Confidence: 0.65
|
||||
|
||||
### Axis 8 — Governance, Decision-Making, and Communication
|
||||
- **Q1**: Who is the decision-maker when two parties disagree?
|
||||
- Evidence: Single owner; D-018/D-019 carry; escalation hooks codified (config.json).
|
||||
- Answer: Unambiguous; no dual-executive stall possible.
|
||||
- Confidence: 0.90
|
||||
- **Q2**: Governance cadence and escalation pattern — useful in a crisis?
|
||||
- Evidence: Per-phase ship + gates; the pipeline halted itself at the v0.2 gate correctly; deploy is owner-directive-only. **New v0.3 wrinkle**: P4's phase-ship DoD requires a host deployment that D-019 forbids without a directive — the first time a phase's success criteria are contingent on a mid-milestone owner action. T-4.9 acknowledges it but does not resolve the contingency.
|
||||
- Answer: The escalation pattern is crisis-proven, but this dependency is new and unresolved — if the owner declines the directive at P4, REQ-83..85 ship unproven-on-host and the milestone gate presents hosted requirements on faith. Escalated as G-111.
|
||||
- Confidence: 0.70
|
||||
- **Q3**: What is being omitted from status reports?
|
||||
- Evidence: `---ci---` honesty verified historically. The channel where optimism leaked this cycle is the research file: C3-11 asserts "verified states.py" for a claim that is false — asserted verification that was not re-run. This grill re-ran it.
|
||||
- Answer: Status reporting remains honest; asserted-but-unre-run verification is the leak channel, and the fix is cultural (G-101's test pin makes the claim checkable).
|
||||
- Confidence: 0.80
|
||||
- **Q4**: Is there a stop-the-project trigger?
|
||||
- Evidence: Owner rejection precedent (twice in v0.1); D-019 is the hosting-scope-specific stop trigger — no public cutover without directive; merge halts at the gate (D-018).
|
||||
- Answer: Yes, and the hosting design adds a second one: the staged/public split (G-107) means exposure is a discrete owner decision, not a gradual slide.
|
||||
- Confidence: 0.85
|
||||
|
||||
### Axis 9 — Change, Adoption, and Operational Readiness
|
||||
- **Q1**: Who will use this, how does their work change, and what's in it for them?
|
||||
- Evidence: Admin-managed accounts (REQ-70) — the owner creates the user set; work change = login + hosted real inference with per-user keys vs. local setup; UX Acceptance Criteria preserve the ≤3-click path with login as a once-per-session step.
|
||||
- Answer: Small, known, sponsor-adjacent user set; adoption politics minimal, value concrete.
|
||||
- Confidence: 0.70
|
||||
- **Q2**: Is ops involved now, or handed a finished product at go-live?
|
||||
- Evidence: The pipeline is ops; deploy.sh extends with paired rollback for both services (T-4.4); the startup sweep is the redeploy safety net (T-1.8); volume ownership handled (chown 10001, T-4.4). Gaps found: no disk assert/prune (G-106), undefined "staged" (G-107), KEK operational posture undocumented (G-105).
|
||||
- Answer: Ops is built-in; three hygiene gaps bound.
|
||||
- Confidence: 0.65
|
||||
- **Q3**: What is the rollback plan?
|
||||
- Evidence: Paired `-prev` containers for BOTH services (T-4.4); mock-dev default preserved at the code level (REQ-75/D-112); pre-hosted artifacts disposable (D-106); sessions durable across restarts (D-101).
|
||||
- Answer: Multi-layer and codified — the strongest single dimension of this plan.
|
||||
- Confidence: 0.80
|
||||
- **Q4**: Have success criteria been validated with the people who will judge success?
|
||||
- Evidence: ROADMAP v0.3 success criteria are concrete (401 curl, SSE unbuffered, two-user isolation, live-mode default on the staged instance); owner gate precedent enforces criteria. **Found**: REQ-78's own text promises "checkable follow-ups" while D-114/T-3.5 derive answered-iff-DONE over a GET-only route — the approved prototype's working checkbox (`library-view.tsx:463-471`) cannot hold live.
|
||||
- Answer: One criterion-vs-implementation mismatch found; bound as honest degradation (G-109) rather than silent reversion.
|
||||
- Confidence: 0.65
|
||||
- Decision: G-109 — live-mode task status is derived (open unless run DONE); the inbox checkbox becomes display-honest in live mode (disabled/annotated; mock keeps the free toggle for dev); persisted per-question check state is v0.4+ scope (REQ-45 backlog); pinned by a live-mode component test.
|
||||
|
||||
### Meta — Closing Review
|
||||
- **Q1**: If you were the auditor, what would you flag?
|
||||
- Evidence: The false "verified" claim (C3-11 sweep legality vs `states.py` PLANNING); the REQ-75 triple mapping; the two stale out-of-scope rows contradicting the same-file supersession; C3-19 vs PLAN pitfall-phase mapping.
|
||||
- Answer: Flag #1 is the sweep bug — a restart-path correctness claim that would have detonated at the first hosted redeploy, the exact moment the milestone's value proposition is under evaluation. Flags #2-4 are the recurring phase-mapping/bookkeeping class (G-001's relatives), now bound (G-102/G-103).
|
||||
- Confidence: 0.80
|
||||
- **Q2**: What is the project not doing that it should?
|
||||
- Evidence: Found across axes: exposure hardening (password policy, login rate limit — G-104); KEK-loss posture (G-105); deploy disk hygiene (G-106); staged-deploy definition (G-107); P3 pressure valve (G-108); task-toggle honesty (G-109); cost posture statement (G-110).
|
||||
- Answer: Seven omissions, all bound. Nothing found suggests the project is doing something it should not — engine-contract preservation holds everywhere checked.
|
||||
- Confidence: 0.75
|
||||
- **Q3**: What is the simplest version that delivers 80% of the value?
|
||||
- Evidence: The owner explicitly picked "one milestone"; deferring live surfaces would re-create the G-009 mock-bound state the owner just moved past — the lever is not deferral.
|
||||
- Answer: The pre-agreed lever is the P3 split (G-108), decided now while it is cheap — not surface deferral, which would contradict the owner's directive.
|
||||
- Confidence: 0.65
|
||||
- **Q4**: What must be true for this to succeed in the next 90 days, and is it true today?
|
||||
- Evidence: (a) D-101..D-115 workable against the tree — re-verified (11/12 sampled exact, 1 false and bound). (b) Owner engagement — evidenced same-day. (c) The KEK provisioned by P2 — trivial owner action, posture bound (G-105). (d) The P4 staged host deployment authorized — currently NOT true; D-019 requires a directive the plan does not pre-arrange (G-111).
|
||||
- Answer: (a)(b)(c) true or evidenced; (d) becomes true when the owner records the pre-authorization. That escalation is the gating action of this grill.
|
||||
- Confidence: 0.70
|
||||
|
||||
### Binding Decisions
|
||||
| ID | Axis | Decision | Confidence |
|
||||
|----|------|----------|-----------|
|
||||
| G-101 | 3/7 | Startup sweep must handle PLANNING-state runs: RESEARCH §7/C3-11's "CANCELLED legal from every non-terminal state" is false (`states.py`: PLANNING → {AWAITING_APPROVAL, FAILED}). Add PLANNING→CANCELLED to `_LEGAL_TRANSITIONS` or document the sweep as an explicit transition-validation bypass; pin with a PLANNING-fixture→CANCELLED sweep test in T-1.8. | 0.90 |
|
||||
| G-102 | 2 | Fix the stale out-of-scope rows contradicting the recorded supersession: REQUIREMENTS.md Out-of-Scope ("Auth / multi-tenancy") and PROJECT.md Out of Scope ("multi-tenant/auth") — replaced with the honest v0.3 posture (auth/multi-user in scope; open self-serve registration and OAuth out; admin-managed accounts only, OAuth v0.4+). | 0.95 |
|
||||
| G-103 | 2/5 | Phase-mapping integrity (G-001 precedent): bind REQ-75's split — delegation-end half closes in P3 (pinned by the standalone-client/no-mock-delegation tests), hosted-live-default half in P4 (pinned by the live-mode smoke marker); fix the traceability row (REQ-75..82 → REQ-76..82 + REQ-75 split note); declare PLAN's pitfall-closure mapping authoritative over C3-19's compressed phase prose. | 0.85 |
|
||||
| G-104 | 2/3 | Public-exposure hardening, budgeted honestly: (a) minimum password length ≥12 enforced at register + admin-create (P1 T-1.4/T-1.5, uniform error messages preserved); (b) per-IP login rate limit at the HAProxy front (stick-table on /api/auth/*, P4 T-4.5) bounding argon2 CPU burn on the single worker; (c) the T-4.8 exposure audit asserts the throttle and confirms uniform 401s (no user enumeration). | 0.80 |
|
||||
| G-105 | 6/7 | KEK-loss posture stated honestly (PLAN T-2.2 + owner docs): RESEARCHX_KEK's only copy is the env-file (never git); loss = per-user encrypted keys unrecoverable (AES-256-GCM + AAD), chain degrades to server/keyless honestly, users re-enter keys under a re-provisioned KEK; the owner MUST back up the KEK off-repo; rotation/re-wrap tooling stays out of v0.3 (recorded, not silently dropped). | 0.85 |
|
||||
| G-106 | 6 | deploy.sh two-image hygiene: pre-build free-disk assertion, post-swap dangling-image prune, and a documented host budget (2 image families × live+prev, 4 containers) — the current script has neither, and P4 doubles the footprint on a single LXC host. | 0.75 |
|
||||
| G-107 | 5/9 | "Staged deploy" defined: candidate pair (researchx + researchx-api) built + run on the LXC host, health checks green, the four smoke assertions executed host-locally against the candidates; public TLS-front cutover remains a discrete owner-directive act (D-019) with post-cutover public assertions (public 401, SSE through proxy) run with the -prev pair armed. | 0.75 |
|
||||
| G-108 | 5 | G-006 precedent applied to P3: may ship as two patches (P3a backend artifacts + read-models + routes + read-model suite; P3b frontend standalone client + surface swaps + Reading Room fidelity + delegation-end) or run mid-phase wave gates, without scope change; tags extend on the v0.3.x line; the milestone release remains the final phase's patch. | 0.70 |
|
||||
| G-109 | 9 | Task-inbox honesty (G-002 lesson): live-mode status is derived (answered-iff run DONE, D-114); the inbox checkbox (`library-view.tsx:463-471`) becomes display-honest in live mode — disabled/annotated, never a silent reversion; mock keeps the free toggle for dev; persisted per-question check state recorded as v0.4+ (REQ-45 backlog); pinned by a live-mode component test in T-3.12. | 0.75 |
|
||||
| G-110 | 1/6 | Cost posture stated in PLAN/PROJECT: server-tier runs bill the owner's OLLAMA_API_KEY; the bound is the admin-managed account set (registration closed, REQ-70); per-user counters (P-13, T-2.6) surface server-tier usage per user for owner audit; no hard spend cap in v0.3 — hard caps are v0.4+ scope if the user set grows; keyless localhost remains the zero-cost path. | 0.70 |
|
||||
|
||||
### Escalations
|
||||
- [G-111] Axis 5/8 — Owner pre-authorization required before P4 executes: P4's own definition of done ("staged smoke suite passes all four assertions", ROADMAP/PLAN T-4.7/T-4.9) requires deploying the candidate container pair to the LXC host — an action D-019 forbids without an owner directive. Without pre-authorization, P4 ships with REQ-83..85 unproven on the real host (the HAProxy 2.8 live config is unread until then) and the milestone gate would present hosting requirements on faith. The owner must either (a) pre-authorize staged host deployments for P4 DoD purposes (candidate pair + host-local smoke, NO public cutover — cutover stays a separate owner directive per G-107), or (b) accept that P4's phase ship carries hosting proven only on a local docker pair, with the host proofs deferred to the gate. This is the first phase in the project whose success criteria are contingent on a mid-milestone owner action; it must not be discovered at P4 ship time. Engineering-side handling is bound in G-107; the authorization itself is unresolved until the owner records it.
|
||||
+36
-26
@@ -1,46 +1,56 @@
|
||||
# Personas — Milestone v0.1 (Prototype)
|
||||
# Personas — Milestone v0.3 (Hosted)
|
||||
|
||||
---
|
||||
active:
|
||||
- name: backend-engineer
|
||||
- name: data-engineer
|
||||
- name: frontend-engineer
|
||||
- name: ci-lead-developer
|
||||
phase_specific: []
|
||||
reason: "v0.1 is a pure frontend prototype (Next.js, mock data). Only the frontend domain has work; backend/data personas activate in v0.2. Lead-developer coordinates decomposition and enforces the ApiClient boundary."
|
||||
phase_specific:
|
||||
- name: security-engineer
|
||||
phases: [1, 2, 4]
|
||||
reason: "v0.3 is the hosted multi-user milestone: P1 lands auth/sessions/CSRF/user-scoping, P2 lands the encrypted key store + credential chain, and P4 exposes the whole surface through a public proxy — security review is needed at all three, but not at P3 (live surfaces are read-model + binding work with no new secret handling) or P5 (the review phase runs the full multi-persona pass anyway)."
|
||||
---
|
||||
|
||||
## Roster
|
||||
|
||||
### backend-engineer
|
||||
- **Domain**: backend
|
||||
- **Active**: true
|
||||
- **Phase-specific**: false
|
||||
- **Frameworks**: fastapi, pydantic v2, argon2-cffi, cryptography, uv, uvicorn, sse-starlette, httpx, pytest, pytest-asyncio
|
||||
- **Constraints**: single uvicorn worker mandatory (in-memory registry + session store); all external I/O lives in adapters — engine imports no vendor SDKs; no blocking calls in async paths (argon2/AES via `asyncio.to_thread`); durable JSON via atomic tmp+os.replace; auth guard is router-dependency-based (`Depends(require_user)`), never BaseHTTPMiddleware (SSE safety); RunRegistry owner-keyed `(owner_id, run_id)` + pruned; engine contracts unchanged (per-run adapter injection); `create_app(auth_enabled=True)` default with parameter-only dev override — never an env var readable in production images
|
||||
- **Territory**: `backend/**`
|
||||
|
||||
### data-engineer
|
||||
- **Domain**: data
|
||||
- **Active**: true
|
||||
- **Phase-specific**: false
|
||||
- **Frameworks**: pydantic v2 (schemas), pytest
|
||||
- **Constraints**: owns the new pydantic schema contracts: User/Session, per-user Settings, key-store envelope file, `RunState.owner_id` (serialization-compatible), report.json/evidence.json (marker normalization `p-N`→`pN`, frontend Report/Citation compatible), read-model payloads (library/tasks/suggestions/graph/profile); pure data + validation — no I/O in schema modules; every run persists resolved dial values (REQ-04 carries)
|
||||
- **Territory**: `backend/src/researchx/config/**`, `backend/src/researchx/evidence/**` (+ schema review on `backend/src/researchx/auth/**` models)
|
||||
|
||||
### frontend-engineer
|
||||
- **Domain**: frontend
|
||||
- **Active**: true
|
||||
- **Phase-specific**: false
|
||||
- **Frameworks**: next.js (App Router), react 19, typescript (strict), tailwind, vitest
|
||||
- **Constraints**: component-first; server components by default, client components only where interactivity demands; all data access through the ApiClient interface — components never import MockApiClient directly; no secrets or env keys in the frontend; typography care per vision §4
|
||||
- **Frameworks**: next.js (App Router), react 19, typescript (strict), tailwind, vitest, openapi-fetch, eventsource
|
||||
- **Constraints**: components never import the mock or generated client directly — only the ApiClient boundary does; PLANNED interface additions (login/logout/getSession, admin hooks) documented before P1; fetch `credentials:"include"` + `X-ResearchX-Request` on all mutations; EventSource `withCredentials: true`; build-time NEXT_PUBLIC_* inlining profiles (mock dev default / live hosted with empty same-origin base); zero component rewrites in live swaps (REQ-54); no secrets or env keys in the frontend
|
||||
- **Territory**: `frontend/**`
|
||||
|
||||
### security-engineer (phase-specific: P1, P2, P4)
|
||||
- **Domain**: security
|
||||
- **Active**: true while P1/P2/P4 execute; deactivated between and after P4
|
||||
- **Phase-specific**: true
|
||||
- **Frameworks**: argon2-cffi, cryptography (AESGCM), httpx, pytest, STRIDE analysis
|
||||
- **Constraints**: KEK from env-file 'keys' scope only — never shell env, never logged, never in git, never in API responses (masked status only); AAD binds ciphertext to user_id; opaque tokens 32-byte urlsafe; SameSite=Lax + custom-header CSRF verified end-to-end; cross-user 404s provable by test; SSRF guard carries to any new fetch path; hosted exposure audit (401 on public /api, /healthz the only unauthenticated route); no `auth_enabled` env-var backdoor in production images
|
||||
- **Territory**: `backend/src/researchx/auth/**` (P1/P2), `.ciagent/release/**` + hosting config review (P4)
|
||||
- **Removal**: deactivate after P4 ships
|
||||
|
||||
### ci-lead-developer
|
||||
- **Domain**: coordination
|
||||
- **Active**: true
|
||||
- **Phase-specific**: false
|
||||
- **Frameworks**: ciagent workflows, git discipline
|
||||
- **Constraints**: enforce persona territories; ApiClient boundary review; commit discipline with ---ci--- blocks; phase-boundary file updates only (no .ciagent/ writes during task execution)
|
||||
- **Territory**: `.ciagent/**`, repo root config files
|
||||
|
||||
### Deactivated (v0.1)
|
||||
|
||||
#### backend-engineer
|
||||
- **Domain**: backend
|
||||
- **Active**: false
|
||||
- **Reason**: No backend in the prototype milestone; the FastAPI backend is milestone v0.2 (blocked until prototype approval)
|
||||
- **Frameworks**: fastapi, pydantic, uv (activates in v0.2)
|
||||
- **Territory**: `backend/**` (does not exist yet)
|
||||
|
||||
#### data-engineer
|
||||
- **Domain**: data
|
||||
- **Active**: false
|
||||
- **Reason**: No database/schema work in v0.1; evidence store and citation ledger models arrive in v0.2; prototype uses typed fixtures
|
||||
- **Frameworks**: pydantic schemas (activates in v0.2)
|
||||
- **Territory**: `backend/src/researchx/evidence/**` (does not exist yet)
|
||||
|
||||
### Custom personas
|
||||
None required for v0.1. The security-auditor persona activates in v0.2 phase 2 (trust loop: secrets handling, liveness verification). No CLI surface exists in this milestone, so no cli-engineer.
|
||||
- **Constraints**: enforce persona territories; adapter/engine boundary review (I/O only in adapters); commit discipline with ---ci--- blocks; phase-boundary file updates only (no .ciagent/ writes during task execution); gates green before every ship; deploy.sh two-service extension (build both images, paired rollback containers, health on /healthz + /ask, live-mode build args asserted in the smoke test); secrets only from .ciagent/.env.secrets; no deploys without owner directive (D-019) and merge-to-main halts at the owner gate (D-018)
|
||||
- **Territory**: `.ciagent/**`, repo root config files (README, VERSIONING, .gitignore, Dockerfiles/deploy scripts)
|
||||
+244
-35
@@ -1,54 +1,263 @@
|
||||
# PLAN — Milestone v0.1 Phase 8 (FINAL): Review + Approval Re-presentation + Ship
|
||||
# PLAN — Milestone v0.3 (Hosted): Working Plan for Phases 1–4
|
||||
|
||||
---
|
||||
phase: 8
|
||||
milestone: v0.1
|
||||
status: executed
|
||||
phase: 0
|
||||
milestone: v0.3
|
||||
status: planned
|
||||
must_haves:
|
||||
- "Full-milestone 2nd final review: PASS-WITH-FIXES — double-compose race + dead drawer-keyboard P1s fixed with regression tests (90/90); P6/P7 fixes verified holding"
|
||||
- "Audit: PASS-WITH-NOTES — reconstruction, tags, file/branch/commit discipline, secrets sweep green; notes applied (ARCHITECTURE §7 + Build Order, root README /ask row, VERSIONING table, About string, package.json version)"
|
||||
- "Final phase patch v0.1.8 shipped — milestone release candidate, presented at the REQ-62 owner approval gate with UAT staged per owner cadence"
|
||||
- "Engine contracts unchanged: all v0.3 work lands outside the engine — per-run adapter injection, owner-scoped storage constructors, owner_id stamping via make_run_config; no methodology/engine protocol rewrites"
|
||||
- "No secret values in git, logs, traces, or API responses: model keys masked (last 4 chars) at the API boundary and encrypted at rest (AES-256-GCM, KEK from the env-file 'keys' scope), passwords argon2id-hashed and never emitted"
|
||||
- "User scoping provable end to end: runs/<user_id>/<run_id>/ partitioning, owner-keyed RunRegistry, disk read-models — cross-user access 404s, pinned by test in every phase that touches the data flow"
|
||||
- "Every phase ships a patch (v0.3.1..v0.3.4; P5 tags v0.3.5 = the milestone release); gates green before every ship; pipeline halts at the merge_to_main owner approval gate (D-018); no deploys without owner directive (D-019)"
|
||||
- "openapi.json snapshot committed and the generated frontend client regenerated from the snapshot (never a live server) in every phase that touches routes; schema-drift check wired into the gates (G-005)"
|
||||
- "Zero-key test discipline: every CI suite runs on REQ-31 fakes with auth via create_app(auth_enabled=False) or dependency_overrides; keyed/live tiers are opt-in, skipped by default"
|
||||
- "Single uvicorn worker in every serving mode — the in-memory RunRegistry and session store are single-process by design"
|
||||
- "Mock demoted to dev/test fixture: the code default of RESEARCHX_API_MODE stays mock; after P3 no live-mode surface delegates to MockApiClient; hosted builds inline NEXT_PUBLIC_RESEARCHX_API_MODE=live (REQ-75)"
|
||||
- "G-101: startup sweep handles PLANNING-state runs — add PLANNING→CANCELLED to _LEGAL_TRANSITIONS (or document the sweep as an explicit validation bypass); pinned by a PLANNING-fixture→CANCELLED sweep test in T-1.8"
|
||||
- "G-104: public-exposure hardening — password minimum length ≥12 at register + admin-create (T-1.4/T-1.5); per-IP login rate limit at the HAProxy front (stick-table on /api/auth/*, T-4.5); exposure audit asserts the throttle + uniform 401s (T-4.8)"
|
||||
- "G-105: KEK-loss posture documented (T-2.2 + owner docs): loss = per-user keys unrecoverable, honest degradation to server/keyless, re-entry under a re-provisioned KEK; owner backs up the KEK off-repo; rotation tooling out of v0.3 (recorded)"
|
||||
- "G-106: deploy.sh hygiene — pre-build free-disk assertion + post-swap dangling-image prune + documented host budget (2 image families × live+prev)"
|
||||
- "G-107: 'staged deploy' = candidate pair built + run on the LXC host, health green, four smoke assertions host-locally; public TLS-front cutover stays a discrete owner-directive act with -prev armed"
|
||||
- "G-108: P3 pressure valve (G-006 precedent) — may ship as P3a (backend artifacts + read-models + routes + suite) and P3b (standalone client + swaps + fidelity + delegation-end), tags extending on the v0.3.x line, without scope change"
|
||||
- "G-109: task-inbox honesty — live-mode status is derived (answered-iff run DONE); the checkbox is display-honest in live mode (disabled/annotated; mock keeps the free toggle); persisted check state is v0.4+ (REQ-45 backlog); pinned by a live-mode component test in T-3.12"
|
||||
- "G-110: cost posture stated — server-tier runs bill the owner's OLLAMA_API_KEY; the bound is the admin-managed account set; per-user counters surface server-tier usage; no hard spend cap in v0.3 (hard caps v0.4+); keyless localhost remains the zero-cost path"
|
||||
personas:
|
||||
- backend-engineer
|
||||
- data-engineer
|
||||
- frontend-engineer
|
||||
- "security-engineer (phase-specific P1/P2/P4)"
|
||||
- ci-lead-developer
|
||||
requirements: [REQ-62]
|
||||
requirements: [REQ-69, REQ-70, REQ-71, REQ-72, REQ-73, REQ-74, REQ-75, REQ-76, REQ-77, REQ-78, REQ-79, REQ-80, REQ-81, REQ-82, REQ-83, REQ-84, REQ-85]
|
||||
---
|
||||
|
||||
## User-Facing Surface
|
||||
|
||||
The extended prototype presented for owner approval: everything from v0.1.6 (shell, library, settings, avatar) plus the v0.1.7 ask-intelligence layer — open-question suggestions and the refine dialogue on the New Research view. Releases v0.1.0–v0.1.8; v0.1.8 is the extended-prototype release candidate.
|
||||
The hosted multi-user product: the full approved frontend — a login screen; Ask → Choose → Plan & Run → Reading Room; the app shell with research library, project graph, task inbox, follow-up suggestions, refine dialogue, settings with masked model-key entry and resolved credential tier, avatar user menu with real profile — now live-bound end to end against the FastAPI backend with **zero live-mode delegation to the mock** (D-112), user-scoped (`runs/<user_id>/<run_id>/`), with per-user encrypted model keys resolving user → server → keyless per run, and deployed as a two-service stack (uvicorn single worker + Next.js live build) behind an SSE-safe HAProxy with TLS — real inference as the product default. Releases v0.3.0–v0.3.5.
|
||||
|
||||
## Happy Path (owner review script)
|
||||
|
||||
1. `cd frontend && pnpm install && pnpm dev` → http://localhost:3000 → shell renders on /ask
|
||||
2. Ask → question → Research this (click 1)
|
||||
3. Choose → Deep Report card → Start research (click 2)
|
||||
4. Plan & Run → Approve plan (click 3) → live trace → pause/resume/cancel
|
||||
5. Read the report → hover [n] → exact quote; ledger; export Markdown/JSON
|
||||
6. New research → click an open-question suggestion → box fills (no auto-start) → edit → Research this
|
||||
7. New research → type a draft → Refine question → answer the 3 clarifying questions (skip the optional two) → Use this → question + context filled → Research this
|
||||
8. Library → folder tree → stored report opens its own report; Connections panel; ⌘K palette
|
||||
9. Avatar → user menu → Settings → API keys masked, methodology defaults live preview
|
||||
10. Owner decision: approve → v0.1.8 published + milestone merge to main; or request changes
|
||||
|
||||
## What this phase did
|
||||
|
||||
1. **Review** (multi-persona, P7-weighted, probe-verified): verdict PASS-WITH-FIXES. P1s: Enter at the last exchange question composed twice (stale section-level keydown guard re-ran submitAnswer after the input guard advanced — invisible with the deterministic mock, a doubled paid POST the day v0.2 wires the endpoint) and Enter on focused drawer buttons was dead (section preventDefault swallowed the click). Fixed: single Enter path (input guard owns Enter; buttons self-activate); error state gained a Keep original button (mouse-user trap). 2 regression tests (single-compose spy, Enter-on-button); suite 88 → 90. Adversarial probe: no drawer control can submit the outer form. P6/P7 fixes verified holding.
|
||||
2. **Audit**: PASS-WITH-NOTES — tags v0.1.0–v0.1.7 verified (annotated, on-branch, mapped to ship records); DEPLOY-RECORD.md tracked + accurate; branch hygiene exact; commit discipline clean (---/ci--- closer holding since the P6 regression); secrets clean across history; REQ-67/68 spot-checked as genuinely delivered; gates re-run live (90/90). Notes applied: ARCHITECTURE.md §7 + Build Order extended with the ask-intelligence layer; root README /ask row updated; VERSIONING table → v0.1.8; settings About + package.json → v0.1.8.
|
||||
3. **Ship (this commit sequence)**: PR #8 (phase/08 → milestone), squash-merge, tag v0.1.8, release with full milestone summary, v0.1.8-uat staged on the LXC host per owner cadence. Per REQ-62 and the prototype_approval escalation hook, the milestone merge to main does NOT proceed autonomously — the pipeline halts at the gate for the recorded owner decision.
|
||||
1. `cd backend && uv sync && uv run pytest` — all suites green with zero API keys: auth suite (`test_auth.py`), key-store tamper tests, read-model tests, restart-sweep test, engine/trust/adversarial suites under the `auth_enabled=False` harness; `uv run pytest -m live` skips cleanly.
|
||||
2. `cd frontend && corepack pnpm install && corepack pnpm test` — component + live-binding suites green; typecheck + lint green.
|
||||
3. Local live-mode full loop: `uv run uvicorn researchx.api.main:app --port 8000` (env-file with `RESEARCHX_KEK` + server model key) + `NEXT_PUBLIC_RESEARCHX_API_MODE=live NEXT_PUBLIC_API_BASE_URL=http://localhost:8000 corepack pnpm dev` → http://localhost:3000 → register (first user = admin) → login → Settings: enter model key (shows masked status only), config shows resolved tier → Ask a question → Research this → pick Deep Report → Start research → approve the plan (≤3 clicks on defaults) → live SSE trace → Reading Room: hover a citation shows the stored quoted passage; ledger panel; export Markdown/JSON with real fields → Library shows the run in its auto-categorized folder; graph/tasks/suggestions surface the run's open questions; refine dialogue runs on the real planner.
|
||||
4. Multi-user isolation: admin creates a second user; that user sees zero of the first user's runs, graph, tasks, or suggestions; the first user's key never affects the second user's resolved tier.
|
||||
5. Hosted: `deploy.sh` → both services up + healthy; `curl https://<origin>/api/runs` → 401 unauthenticated; `curl https://<origin>/healthz` → 200; login through the public URL; SSE trace streams unbuffered through the proxy; per-user key honored end to end (public deployment per owner directive, D-019).
|
||||
6. Owner decision at the P5 gate: approve → v0.3.5 = milestone release, merge to main; or direct changes → binding fix cycle.
|
||||
|
||||
## UX Acceptance Criteria
|
||||
|
||||
- Owner demo script runs clean on the shipped code (probe- and test-verified)
|
||||
- Suggestions + refine never auto-submit; drafts stay untouched on dismiss
|
||||
- The 3-click criterion still pinned green on AskView (affordances opt-in)
|
||||
- Honest captions everywhere; keyboard accessible throughout
|
||||
- The ≤3-click research path (Ask → Choose → Run on defaults) is preserved for an authenticated user; login is a once-per-session step (14-day sliding) adding zero clicks to the path.
|
||||
- Reading Room parity with the approved mock: page-anchored citations whose hover shows the exact stored quoted passage; a real ledger panel; exports with real entry data — no fabricated fields anywhere.
|
||||
- Honesty in live mode: resolved credential tier visible (user|server|keyless), key-store-disabled stated honestly, no synthetic suggestions, honest empty states for fresh users.
|
||||
- Suggestions click-fills-the-box (never auto-starts); refine is opt-in and model-driven.
|
||||
- Keys masked everywhere: no secret value is ever rendered, logged, or exported.
|
||||
- Cross-user isolation is total and invisible: no surface leaks another user's runs, questions, or key status.
|
||||
|
||||
## Verification
|
||||
## Planned ApiClient Extensions (C3-18 — documented before P1; this list is exhaustive)
|
||||
|
||||
- Gates: typecheck ✓ lint ✓ tests 90/90 ✓ build ✓ (re-run after review fixes, plus by audit)
|
||||
- Regression tests pin: single-compose, Enter-on-button, focus return, Enter-guard, no-auto-submit
|
||||
- Post-hoc v0.2 log: compose-endpoint idempotency key; freeze draft at drawer-open; confirm-overwrite when context non-empty; Try-again from error state; textarea answers; aria-live progress
|
||||
- `login(email, password): Promise<UserProfile>` — POST /api/auth/login (sets the session cookie)
|
||||
- `logout(): Promise<void>` — POST /api/auth/logout
|
||||
- `getSession(): Promise<UserProfile | null>` — GET /api/auth/me (401 → null)
|
||||
- Admin hooks (API-first in v0.3; no dedicated admin screen): `listUsers()`, `createUser(input)`, `deactivateUser(id)` — GET /api/admin/users, POST /api/admin/users, POST /api/admin/users/{id}/deactivate
|
||||
- MockApiClient gains matching no-op/local implementations (standalone mock preserved — REQ-31/REQ-54); live implements against the routes. The v0.2 pause/resume extensions (G-003) carry unchanged. No other interface changes are planned for v0.3.
|
||||
|
||||
## Scope Guard
|
||||
## Phase 1 — Auth + Sessions + User Scoping (ships v0.3.1)
|
||||
|
||||
Milestone merge to main, v0.1.8 publication, v0.2 unblocking, and all v0.2 P1 work wait on the owner approval gate (REQ-62, prototype_approval escalation hook). The pipeline does not proceed autonomously past this commit.
|
||||
**Personas** (lead-developer decomposition by territory): backend-engineer (lead — auth package, storage partitioning, engine/app wiring, sweep/pruning), data-engineer (auth + state pydantic contracts), security-engineer (phase-specific — auth adversarial suite + STRIDE), frontend-engineer (session surface, snapshot gate), ci-lead-developer (ship).
|
||||
|
||||
**Requirements covered**: REQ-69, REQ-70, REQ-71
|
||||
|
||||
**Task waves**:
|
||||
|
||||
*Wave 1 — contracts + stores (parallel)*
|
||||
- **T-1.1 (backend-engineer)**: Data root + user store — `researchx.config.paths.data_root()` (RESEARCHX_DATA_DIR → `backend/var/researchx` dev; D-107); `auth/users.py`: User model (user_id = 8-char slug, validation `^[a-z0-9]{8}$`, D-105; email; argon2id via argon2-cffi `PasswordHasher()` defaults, hash/verify/rehash all in `asyncio.to_thread`, D-102; display_name; role; active; created_at), `users.json` at the data root, atomic rewrite (D-017 idiom). Pyproject gains `argon2-cffi>=23.1,<26` (no python-multipart).
|
||||
- **T-1.2 (backend-engineer)**: Session store — `auth/sessions.py`: opaque `token_urlsafe(32)` tokens, in-process dict + durable `sessions.json` (atomic), `Session {token_hash, user_id, created_at, last_seen, expires_at}`, 14-day sliding expiry via `touch()` with >60s persist throttle, token rotation at login, immediate revocation (logout deletes the presented token; deactivation deletes all the user's tokens) (D-101).
|
||||
- **T-1.3 (data-engineer)**: Auth + state contracts — pydantic User/Session schemas, auth route request/response models, `RunState.owner_id: str | None = None` (serialization-compatible, no migration per D-106); old state files must deserialize.
|
||||
|
||||
*Wave 2 — enforcement (depends W1)*
|
||||
- **T-1.4 (backend-engineer)**: Guard + auth routes + CORS — `auth/guard.py` `require_user` dependency (session resolve → 401; `X-ResearchX-Request` header required on non-GET → 403; inactive user → 401; sliding touch); `auth/routes.py` register/login/logout/me with httpOnly `researchx_session` cookie (SameSite=Lax, path=/, Secure hosted-only flag; no Domain attribute); uniform 401 "invalid email or password"; register open only while the user store is empty — first user `role="admin"`, then 403 (no await between check and insert except the argon2 hash; single worker = race-free, D-104); public router (`/api/auth/*`, `/healthz` outside /api per D-103) + protected router (everything else) via `APIRouter(dependencies=[Depends(require_user)])` — **router dependencies, not middleware** (C3-01; no BaseHTTPMiddleware near SSE); CORS: `allow_credentials=True` + explicit localhost origins, ordered so preflight OPTIONS answers without 401 (C3-04); login/register POSTs also require the CSRF header (C3-05).
|
||||
- **T-1.5 (backend-engineer)**: Admin routes — POST /api/admin/users (admin-guarded create with admin-set initial password), GET /api/admin/users (list), deactivate (kills all sessions; data retained) — REQ-70.
|
||||
- **T-1.6 (backend-engineer)**: Storage partitioning — `LocalStorageAdapter(base_dir, owner_id)` with **required** owner_id (validated), `run_dir → base_dir/runs/<owner_id>/<run_id>/`, + `list_run_ids()` (read-models scan later); `StorageAdapter` protocol gains `append_run_artifact` (the actor already calls it); `read_run_artifact` error message embeds the owner path; consolidate the duplicate run-id generators (P-14). Protocol otherwise byte-for-byte unchanged (D-106).
|
||||
|
||||
*Wave 3 — engine/app wiring (depends W2)*
|
||||
- **T-1.7 (backend-engineer)**: Owner-scoped app wiring — `make_run_config(..., owner_id)` stamps `state.owner_id`; RunActor untouched (engine contracts unchanged); `RunRegistry` keyed `(owner_id, run_id)` — cross-owner access 404; routes construct the owner-scoped storage per request from the auth dependency; `create_app(*, auth_enabled=True)` default with **parameter-only** dev override resolving a synthetic dev user (`user_id="dev00000"`, role admin — C3-16, never an env var readable in production); `dev_bridge.py` stays explicitly `auth_enabled=False` (P-5); `engine/demo.py` owner arg (P-9); remove the dead duplicate `return EventSourceResponse` (app.py:331, P-11); mechanical `owner_id` constructor args across the 8 test files (RESEARCH §5 touchpoint list).
|
||||
- **T-1.8 (backend-engineer)**: Startup sweep + registry pruning — on `create_app` startup, rewrite every non-terminal `state.json` under the data root as CANCELLED (D-115; legal from every non-terminal state; in-flight actors die with the process); registry drops terminal `(actor, bus)` entries once no live SSE subscriber (refcount in the events route — P-6); events-404-after-prune/restart documented (trace persists on disk).
|
||||
|
||||
*Wave 4 — adversarial + frontend + snapshot (depends W3)*
|
||||
- **T-1.9 (security-engineer + backend-engineer)**: Auth suite — `backend/tests/test_auth.py` with `auth_enabled=True` (RESEARCH §11): first-user-admin (+403 register after), wrong-password 401 uniform, logout revocation, 401 on all protected routes, CSRF header rejection, cross-user 404 (all run endpoints), admin create/deactivate (deactivation kills sessions), session expiry, cookie flags, no password-hash material in any response; pins P-5 and the authorization half of P-8.
|
||||
- **T-1.10 (frontend-engineer)**: Session surface — ApiClient extensions per C3-18 (mock no-ops; live against routes); login screen (email + password, error states); live-mode route guard redirecting unauthenticated users to /login; user menu shows the real profile + logout; fetch discipline: `credentials:"include"` + `X-ResearchX-Request` header on every mutation (C3-05); dev docs pin same-hostname origins (`localhost` both sides — port is not part of same-site computation).
|
||||
- **T-1.11 (frontend-engineer)**: Snapshot regen gate — export + commit `backend/openapi.json` (auth/admin routes included), regenerate `frontend/src/lib/api/generated/` from the snapshot only, wire the schema-drift check into the gates, operation-ids stable (G-005).
|
||||
|
||||
*Wave 5 — ship*
|
||||
- **T-1.12 (ci-lead-developer)**: Phase ship — all gates green (backend ruff/mypy/pytest incl. `test_auth.py`; frontend lint/typecheck/vitest; drift gate), tag **v0.3.1** + release; REQUIREMENTS/ROADMAP traceability rows updated.
|
||||
|
||||
**Must-haves (verification-oriented)**:
|
||||
- Unauthenticated access to any `/api` route except register/login → 401; register 403s once the user store is non-empty — pinned by `test_auth.py`.
|
||||
- First registered user is admin; all subsequent accounts admin-created; deactivation kills sessions and retains data.
|
||||
- No user can read or act on another user's data: owner-keyed registry → cross-owner 404 on run GET/lifecycle/report/ledger/events — pinned by test.
|
||||
- Storage layout is `runs/<user_id>/<run_id>/`; `owner_id` recorded in every persisted run state.
|
||||
- Sessions are server-side with httpOnly SameSite=Lax cookies, immediate revocation, sliding expiry; CSRF header enforced on non-GET (EventSource GET exempt).
|
||||
- Engine contracts unchanged; existing zero-key suites green with only mechanical `owner_id` constructor changes.
|
||||
- `openapi.json` snapshot committed; generated client regenerated from it; drift gate fails on intentional drift (proof).
|
||||
- No run survives a restart in a non-terminal state (sweep test); terminal registry entries prune (leak test).
|
||||
- No password hash or key material in any API response or log.
|
||||
|
||||
**Pitfall closures (RESEARCH §12)**: **P-4** (CORS `allow_credentials` + ordering), **P-5** (dev_bridge explicitly auth-off, pinned by the suite), **P-6** (registry pruning), **P-7** (owner_id + partitioning), **P-9** (demo.py owner arg), **P-10** (layout test re-pinned to the partitioned path), **P-11** (dead code removed), **P-14** (run-id generators consolidated). P-8's authorization half (owner-keyed 404s) closes here; its registry-independence half closes in P3.
|
||||
|
||||
**Success criteria (testable)**:
|
||||
- `test_auth.py` green covering the full §11 list; two-user isolation test proves zero visibility across every run endpoint.
|
||||
- Register → admin; second register → 403; admin creates + deactivates (sessions die) — all route-tested.
|
||||
- Non-GET without the header → 403; with header + session → 2xx.
|
||||
- Disk layout test proves `runs/<owner>/<run_id>/`; restart-sweep test proves non-terminal → CANCELLED across an app restart; pruning test proves terminal entries drop with zero SSE subscribers.
|
||||
- Drift gate: intentional schema change fails the gate; regen green.
|
||||
- Gates green; patch v0.3.1 shipped.
|
||||
|
||||
## Phase 2 — Per-User Settings + Model Keys (ships v0.3.2)
|
||||
|
||||
**Personas**: backend-engineer (lead — key store, settings store, model factory, config rework, production entrypoint), data-engineer (settings + key-envelope schemas), security-engineer (phase-specific — key-store adversarial suite), frontend-engineer (settings live-binding, snapshot), ci-lead-developer (ship).
|
||||
|
||||
**Requirements covered**: REQ-72, REQ-73, REQ-74
|
||||
|
||||
**Task waves**:
|
||||
|
||||
*Wave 1 — contracts*
|
||||
- **T-2.1 (data-engineer)**: Settings + key-envelope schemas — per-user Settings payload (model names, methodology defaults + resolved dials, appearance slot, masked key status, storage path display-only — D-111); key-store envelope `{v, alg, updated_at, entries: {OLLAMA_API_KEY: {nonce, ct, updated_at}}}` (D-108/D-109); KEK decode rules (urlsafe-b64 or hex, exactly 32 bytes; decode/length errors are typed AdapterErrors).
|
||||
|
||||
*Wave 2 — stores (depends T-2.1)*
|
||||
- **T-2.2 (backend-engineer)**: Key store — `auth/key_store.py`: AES-256-GCM via `cryptography` AESGCM (pyproject gains `cryptography>=43`); KEK = `RESEARCHX_KEK` from the env-file **'keys'** scope (`config.json` secrets.scopes gained `"keys": ["RESEARCHX_KEK"]` in P0; provisioned into `.ciagent/.env.secrets` only — never git); AAD = `user_id` (a copied file under another user fails `InvalidTag`); fresh `os.urandom(12)` nonce per write (never reused under the KEK); `keys/<user_id>.json`, atomic, 0600; crypto in `asyncio.to_thread`; honest degradation: no KEK → disabled (PUT rejected with `{"key_store": "disabled"}`), corrupt entries surface as errors, never a silent tier drop (C3-06). KEK change requires process restart (documented).
|
||||
- **T-2.3 (backend-engineer)**: Settings persistence — `settings/<user_id>.json` store, atomic rewrite, defaults for fresh users, GET/PUT payload shapes (D-111).
|
||||
|
||||
*Wave 3 — routes + factory (depends W2)*
|
||||
- **T-2.4 (backend-engineer)**: Settings + key routes — GET/PUT `/api/config/settings`; model-key PUT accepts a raw key (scope = OLLAMA_API_KEY only, D-109) → encrypted at rest; every response returns configured + masked (last 4) only — the raw key never appears in any response, log, or trace (REQ-73).
|
||||
- **T-2.5 (backend-engineer)**: Per-run model factory — `adapters/model_factory.py`: `ModelAdapterFactory(secrets, key_store, defaults).resolve(user, settings) → (adapter, tier)` resolving user → server → keyless (REQ-74); `OllamaModelAdapter` constructor gains optional `api_key` (precedence over secrets resolve; secrets path back-compat); **one adapter instance per run** — the `_active_base` fallback mutation is per-instance, never shared (P-2 closure); `create_run` resolves + injects (engine contracts unchanged); state records `owner_id` + `credential_tier`.
|
||||
- **T-2.6 (backend-engineer)**: Config status rework — GET `/api/config` resolves the **current user's** tier via the factory without creating a run (kills the `runs._actors` private read — P-1 closure; no cross-user probe leak, no zero-run breakage); probe cached in-process keyed `(user_id, tier, key_fingerprint)` with short TTL, running against the resolved tier; per-user cost counters owner-keyed (P-13 closure; G-007 carries); `resolved_tier` + `key_store: enabled|disabled` surfaced.
|
||||
|
||||
*Wave 4 — production + adversarial + frontend (depends W3)*
|
||||
- **T-2.7 (backend-engineer)**: Production entrypoint — new `researchx.api.main` wiring `create_app(data_root=RESEARCHX_DATA_DIR, factory=<live factory from env-file secrets>, auth_enabled=True)` (P-3 backend closure — the `app.py:359` module global serving fakes + cwd storage leaves production); `app.py` stays the test factory; `main.py` never passes `auth_enabled=False`.
|
||||
- **T-2.8 (security-engineer + backend-engineer)**: Key-store adversarial suite — tamper tests: AAD binding (key file copied under another user_id → `InvalidTag` surfaced), nonce uniqueness across writes, masked-only responses (no raw key material in response/log/trace), disabled-honesty without KEK, corrupt-entry error surfacing; opt-in keyed tier (skipped by default; zero-key CI unchanged) proving a keyed user gets real inference end to end through the REQ-74 chain.
|
||||
- **T-2.9 (frontend-engineer)**: Settings live-binding — `GeneratedApiClient` getSettings/saveSettings → `/api/config/settings`; masked key entry (password-type input → PUT; masked status display; never echoed); getConnectionStatus maps GET `/api/config` (resolved tier user|server|keyless, key_store status, probe, per-user counters); storage directory display-only in live/hosted mode (D-111 — the free-path picker is the local single-user profile only); mock parity preserved standalone.
|
||||
- **T-2.10 (frontend-engineer)**: Snapshot regen — settings/key routes change `openapi.json` → snapshot committed, client regenerated, drift gate green (G-005).
|
||||
|
||||
*Wave 5 — ship*
|
||||
- **T-2.11 (ci-lead-developer)**: Phase ship — gates green; tag **v0.3.2** + release; traceability updated.
|
||||
|
||||
**Must-haves**:
|
||||
- Keys never appear in full in any API response, log, or trace — masked only, pinned by test.
|
||||
- Key store: AES-256-GCM, AAD=user_id, fresh nonce per write, 0600, atomic — tamper-proof pinned by test.
|
||||
- Without a KEK: key store disabled honestly; resolution proceeds server/keyless; never a silent tier drop.
|
||||
- Per-run credential chain user → server → keyless; probe runs against the resolved tier; config status surfaces the current user's tier without creating a run and without reading private registry state.
|
||||
- Shared-adapter fallback bug dead: one adapter per run; `_active_base` mutation can never cross users.
|
||||
- Cost counters owner-keyed.
|
||||
- Engine contracts unchanged; zero-key suites green (fakes, `auth_enabled=False`).
|
||||
- Production entrypoint live-wired; the module-global fake app serves nothing.
|
||||
|
||||
**Pitfall closures**: **P-1** (config_status → factory-resolved probe), **P-2** (per-run factory kills the shared `_active_base` mutation), **P-13** (owner-keyed cost counters), **P-3 backend half** (`researchx.api.main`; the hosting half closes in P4).
|
||||
|
||||
**Success criteria**:
|
||||
- Opt-in keyed tier: a keyed user gets fully real inference end to end; `resolved_tier="user"` in config status.
|
||||
- Zero-key CI: full suite green with no KEK configured — key store reports disabled, tier falls to server/keyless honestly.
|
||||
- Tamper suite green: AAD tamper → InvalidTag surfaced; nonces unique; masked-only responses.
|
||||
- Config status correct with zero runs and per-user isolated (no probe leak).
|
||||
- Drift gate green; gates green; patch v0.3.2 shipped.
|
||||
|
||||
## Phase 3 — Live Surfaces, user-scoped (ships v0.3.3)
|
||||
|
||||
**Personas**: backend-engineer (lead — artifacts, read-models, all new routes), data-engineer (artifact + read-model payload schemas), frontend-engineer (standalone client, all surface swaps, Reading Room fidelity), ci-lead-developer (ship). Security-engineer inactive this phase (no new secret handling — roster reason).
|
||||
|
||||
**Requirements covered**: REQ-76, REQ-77, REQ-78, REQ-79, REQ-80, REQ-81, REQ-82 (+ REQ-75 delegation-end half: after this phase no live-mode surface delegates to the mock)
|
||||
|
||||
**Task waves**:
|
||||
|
||||
*Wave 1 — artifacts + read-models (parallel)*
|
||||
- **T-3.1 (backend-engineer + data-engineer)**: report.json + evidence.json at DONE — persisted inside `_verify_phase` right after ledger/checklist (EvidenceStore is GC'd with the actor — this is the only write point; D-113): `report.json {v, run_id, owner_id, title, question, methodology_slug, generated_at, sections:[{heading, body}], citations:[{passage_id, url, title, quote, health, authority_tier}]}`, `evidence.json {v, run_id, passages:[{passage_id, url, title, quote, text}]}`; **marker normalization at the persistence boundary only**: section bodies `[p-N]` → `[^pN]`, ids `p-N` → `pN` (the Reading Room parses with zero frontend changes); `report.md` keeps raw `[p-N]` — trust-loop regexes untouched (C3-10).
|
||||
- **T-3.2 (backend-engineer + data-engineer)**: Read-models — `api/read_models.py` scans `runs/<owner_id>/` loading `state.json` (+ report.json/ledger.json where needed); skips dotfiles/tmp defensively (D-115); payload schemas shaped to the existing frontend types: library folders (auto-categorization by methodology + deterministic topic tokens from plan sub-questions) + ReportSummary, ReportConnection, ProjectGraph nodes/edges, InboxItem `{id: "<run_id>#<idx>", question, sourceRunId, status}`, SuggestedQuery.
|
||||
- **T-3.3 (backend-engineer)**: Enriched report endpoint — GET `/api/runs/{id}/report` → `{run_id, markdown, report, evidence}` (markdown retained, back-compat); **disk-read, registry-independent** (P-8 full closure); graceful markdown-only fallback for pre-artifact DONE runs (old artifacts disposable per D-106, but never a 500); ledger endpoint likewise disk-read.
|
||||
|
||||
*Wave 2 — routes (depends W1)*
|
||||
- **T-3.4 (backend-engineer)**: Library + connections + graph — GET `/api/library?sort=recent|title` (folders + ReportSummary[] resolving to real runs); GET `/api/library/connections` (shared-source edges ≥1 ledger-URL overlap + topical edges); GET `/api/graph` (same edges in ProjectGraph shape) — REQ-76/77, D-114.
|
||||
- **T-3.5 (backend-engineer)**: Tasks + suggestions — GET `/api/tasks` (open questions = plan sub-questions, answered-iff run reached DONE); GET `/api/suggestions` (open items joined with source run titles; no synthetic suggestions; empty state for fresh users) — REQ-78/79, D-114.
|
||||
- **T-3.6 (backend-engineer)**: Refine + profile — POST `/api/refine/questions {draft}` → 2–3 planner clarifying questions; POST `/api/refine/compose {draft, answers}` → composed refined question + suggested context; planner calls ride the **same per-user credential chain** (P2 factory), honest 503 for a keyless user with no local endpoint — never a hang; GET/PUT `/api/profile` (display_name, email; no deletion) — REQ-80/81. All owner-scoped via the auth dependency; non-GET ⇒ CSRF header applies.
|
||||
|
||||
*Wave 3 — frontend standalone (depends routes + snapshot)*
|
||||
- **T-3.7 (frontend-engineer)**: Snapshot regen — all new routes into the committed `openapi.json` snapshot; client regenerated; drift gate green (G-005).
|
||||
- **T-3.8 (frontend-engineer)**: GeneratedApiClient standalone — every ApiClient method overridden against real endpoints; `extends MockApiClient` **dropped** (a standalone class implementing ApiClient — D-112); request wrapper: `credentials:"include"` + `X-ResearchX-Request` on every mutation; `EventSource` constructed with `{withCredentials: true}`; mock remains a standalone dev/test fixture.
|
||||
- **T-3.9 (frontend-engineer)**: Surface swaps — getReportLibrary, getReportConnections, getProjectGraph, getTaskInbox, getSuggestedQueries, getRefinementQuestions, composeRefinedQuestion, getUserProfile, plus the P2 settings/config methods — all live, zero component rewrites (REQ-54); suggestions click-fills never auto-starts; the refine drawer drives the real exchange.
|
||||
- **T-3.10 (frontend-engineer)**: Reading Room fidelity + honest exports — getReport maps report.json → Report (sections + citations with **real quotes**; tier map official→1, reference→2, else 3); getLedger real entries; exportReport built from real entry data (kills the fabricated `claims_supported: 1` and empty quotes — P-12 closure, REQ-82); hover shows the stored quoted passage — parity with the mock Reading Room v0 (REQ-82); exports round-trip through the backend-schema gate (REQ-34 carries) with citation links intact.
|
||||
|
||||
*Wave 4 — tests + ship (depends W3)*
|
||||
- **T-3.11 (backend-engineer + data-engineer)**: Read-model test suite — `backend/tests/test_read_models.py`: a fixture disk tree (two users; DONE, CANCELLED, in-flight, FAILED runs) asserting library folder categorization + recency/title sorting; shared-source edges (≥1 ledger URL overlap) + topical edges; graph node/edge shape; tasks answered-iff DONE; suggestions = open items joined with run titles, empty for a fresh user, never synthetic; dotfile/tmp skip; **registry-independence test**: all read endpoints correct against a fresh `create_app` over an existing data root with zero in-memory actors (P-8 full-closure proof); artifact tests: report.json/evidence.json written at DONE with normalized markers (`[^pN]` in report.json, raw `[p-N]` in report.md), markdown-only fallback for pre-artifact DONE runs (never a 500).
|
||||
- **T-3.12 (frontend-engineer)**: Live-surface regression suite — component tests run unchanged against the live binding (zero component rewrites proven, REQ-54); ≤3-click path pinned green in live mode; suggestions click-fills-the-box never auto-starts; refine exchange drives the real endpoint on fakes; drift gate green.
|
||||
- **T-3.13 (ci-lead-developer)**: Phase ship — all gates green; tag **v0.3.3** + release; traceability rows updated.
|
||||
|
||||
**Must-haves**:
|
||||
- Every prototype surface (library, connections, graph, tasks, suggestions, refine, profile, settings, Reading Room) works against real user data in live mode — `GeneratedApiClient` is standalone, `extends MockApiClient` is gone (D-112), and no live-mode method delegates to the mock.
|
||||
- Read-models read disk artifacts only, never RunRegistry: every read endpoint is correct after a restart with zero in-memory state; non-terminal runs post-sweep appear as CANCELLED, terminal runs stay readable.
|
||||
- Reading Room parity with the mock: structured sections, page-anchored citations whose hover shows the stored quoted passage, ledger panel from real ledger.json; exports carry real quotes/claims/health/tier — no fabricated fields.
|
||||
- Engine persists report.json + evidence.json at DONE inside `_verify_phase`; `report.md` marker syntax untouched (trust-loop regexes unchanged).
|
||||
- All new routes owner-scoped via the auth dependency — cross-user 404s pinned; non-GET routes CSRF-header-guarded (refine POSTs included).
|
||||
- ≤3-click path green; suggestions never auto-start; honest empty states; no synthetic suggestions.
|
||||
- Snapshot committed, client regenerated from the snapshot only, drift gate green (G-005).
|
||||
- Zero-key discipline intact: refine tests on REQ-31 fakes; keyless refine surfaces an honest 503, never a hang.
|
||||
|
||||
**Pitfall closures**: **P-8 full closure** (registry-independent disk reads — auth half closed in P1, read half here, pinned by the restart test), **P-12** (export fabrication killed — real quotes/claims from report.json/evidence.json). Marker-normalization hazard (§6) closed by construction: normalization at the persistence boundary only, `report.md` raw form pinned by test.
|
||||
|
||||
**Success criteria**:
|
||||
- Read-model suite green over the multi-user fixture tree; fresh-app-over-existing-data-root test proves every read endpoint without the registry.
|
||||
- Live mode: all surfaces live-bound, component tests unchanged, 3-click green, no delegation to mock anywhere in the live client.
|
||||
- Export JSON payloads contain real quotes, real claims_supported, real health/tier — the fabricated `claims_supported: 1` / empty quotes are gone and a test pins their absence.
|
||||
- Fresh user: empty library, empty tasks, empty suggestions — honest, no synthetic content.
|
||||
- Drift gate green; gates green; patch v0.3.3 shipped.
|
||||
|
||||
## Phase 4 — Hosting + Real-Inference Default (ships v0.3.4)
|
||||
|
||||
**Personas**: ci-lead-developer (lead — deploy.sh, HAProxy config, hosting per its repo-root territory), backend-engineer (Dockerfile, production wiring completion, health endpoint), frontend-engineer (live build profile, build args), security-engineer (phase-specific — hosted exposure audit through the public URL).
|
||||
|
||||
**Requirements covered**: REQ-75, REQ-83, REQ-84, REQ-85
|
||||
|
||||
**Task waves**:
|
||||
|
||||
*Wave 1 — container + production wiring*
|
||||
- **T-4.1 (backend-engineer)**: Backend Dockerfile — `python:3.12-slim` + uv from the ghcr image; `uv sync --frozen --no-dev`; `useradd --system --uid 10001 researchx`; `mkdir -p /var/lib/researchx` + chown; `USER researchx`; `CMD ["uvicorn", "researchx.api.main:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "1", "--proxy-headers"]` (REQ-83, C3-13). Secrets are runtime-only via `--env-file` (`RESEARCHX_KEK`, `OLLAMA_API_KEY`, `OLLAMA_BASE_URL`, `TAVILY_API_KEY`) — never baked into the image, never in git.
|
||||
- **T-4.2 (backend-engineer)**: Production wiring completion — `researchx.api.main` verified end to end: `data_root=RESEARCHX_DATA_DIR` (container default `/var/lib/researchx`), live model factory from env-file secrets, `auth_enabled=True` hardwired — **P-3 full closure**: no production path can serve FakeModelAdapter or cwd-based storage; parameter-only auth override confirmed absent from the image (no `RESEARCHX_AUTH`-style env readable anywhere); uvicorn graceful-shutdown behavior documented (in-flight runs exercise `_persist_cancelled` on SIGTERM; `docker rm -f` SIGKILLs — the P1 startup sweep is the safety net).
|
||||
|
||||
*Wave 2 — frontend live build profile*
|
||||
- **T-4.3 (frontend-engineer)**: Live build profile — frontend Dockerfile build stage gains `ARG`/`ENV` before `pnpm build`: `NEXT_PUBLIC_RESEARCHX_API_MODE=live` + `NEXT_PUBLIC_API_BASE_URL=""` (empty = same-origin through HAProxy's `/api` path route); `baseUrl()` treats `""` as same-origin (openapi-fetch relative base, EventSource relative URL); build asserts the empty base (a non-empty base would break same-origin cookies); dev live-mode docs pin `http://localhost:8000` on both sides (hostname rule, C3-15).
|
||||
|
||||
*Wave 3 — deploy + proxy*
|
||||
- **T-4.4 (ci-lead-developer)**: deploy.sh two-service extension — rsync now includes `backend/`; both images built on the host; plain docker-run pairs on the existing LXC idiom (no compose dependency): `researchx-net` bridge network; `researchx-api` backend (env-file, data volume bind, `-p 127.0.0.1:8000:8000` with host-side HAProxy, `--restart` policy); `researchx` frontend (`-p 3000:3000`, live build args); paired rollback containers `researchx-api-prev` + `researchx-prev` both retained; data volume pre-created + `chown 10001` host-side (belt-and-braces against EACCES); health check on BOTH `/healthz` (backend) and `/ask` (frontend) before the swap; idempotent re-run.
|
||||
- **T-4.5 (ci-lead-developer, security-engineer review)**: HAProxy front config — documented + idempotent update to the host's existing HAProxy 2.8 TLS frontend: `acl is_api path_beg /api`, `acl is_health path /healthz` → `backend_api`; `default_backend frontend_next`; `backend_api`: `option httpchk GET /healthz`, `default-server inter 5s fall 3 rise 2`, `server api1 127.0.0.1:8000 check`, `timeout server 3600s`, `timeout tunnel 3600s`; **NO `option http-buffer-response`, NO compression on the API backend** (gzip buffers text/event-stream); `backend frontend_next`: `timeout server 300s`, `server next1 127.0.0.1:3000 check` (REQ-84, C3-14); `timeout client` exceeds the sse-starlette 15s ping interval; the config file is committed as documentation; application to the live host only on owner deploy directive (D-019).
|
||||
- **T-4.6 (backend-engineer)**: `/healthz` verification — unauthenticated `GET /healthz → 200 {"status":"ok"}` (public router from P1, outside `/api` per D-103) proven through the proxy path route.
|
||||
|
||||
*Wave 4 — smoke + exposure audit + ship*
|
||||
- **T-4.7 (ci-lead-developer)**: Hosted smoke assertions — scripted against a staged instance: (1) unauthenticated `curl` to public `/api/runs` → **401**; (2) `/healthz` → **200**; (3) **live-mode marker**: authenticated `/api/config` resolves a real tier and the smoke asserts the build inlined live mode (empty base + live mode, not mock); (4) **SSE-through-proxy smoke**: an authenticated EventSource against the public origin streams a full run's events unbuffered end to end — no buffering, no mid-stream cut, 15s pings hold the connection through `timeout server 3600s` (REQ-84/85).
|
||||
- **T-4.8 (security-engineer)**: Hosted exposure audit — through the public URL: every `/api` route 401s unauthenticated (401-on-public-/api pinned); `/healthz` is the ONLY unauthenticated route; no `auth_enabled` backdoor in the production image (parameter-only, `main.py` never passes False — verified); cookie `Secure` flag set on the TLS-fronted host; secrets sweep over the built image layers and deploy artifacts (zero key values anywhere); STRIDE pass on the proxy surface.
|
||||
- **T-4.9 (ci-lead-developer)**: Phase ship — all gates green; tag **v0.3.4** + release; traceability updated; deployment to the live host staged only on owner directive (D-019) — the smoke suite is written to run against a staged deploy.
|
||||
|
||||
**Must-haves**:
|
||||
- One-command deploy brings up both services; paired rollback containers exist for BOTH services (REQ-83).
|
||||
- Backend container: non-root uid 10001, single uvicorn worker (`--workers 1` mandatory — in-memory registry + session store), secrets runtime-only via `--env-file`, data volume writable by uid 10001.
|
||||
- The hosted instance runs real inference in live mode by default (build-time inlined; REQ-75) with per-user keys honored through the full chain — while the code default stays mock (zero-key dev/CI preserved, REQ-31/D-112).
|
||||
- SSE streams unbuffered through the proxy: no http-buffer-response, no API compression, timeout server/tunnel 3600s, pings unbroken — verified end to end.
|
||||
- Unauthenticated public `/api` → 401 on every route; `/healthz` 200 the only unauthenticated route (REQ-85).
|
||||
- No secret values baked into images, committed, or logged; volume chown 10001.
|
||||
- HAProxy changes documented + idempotent; applied only on owner directive (D-019).
|
||||
|
||||
**Pitfall closures**: **P-3 full closure** (hosting half — the container serves `researchx.api.main` with the live factory; nothing can host fakes). RESEARCH §8/§9 hazards encoded and tested: volume-ownership EACCES (chown 10001 + smoke write), SSE-compression hazard (explicitly absent from the API backend config, asserted in review), ping keepalive (SSE smoke holds ≥ several ping intervals), SIGKILL-on-redeploy (startup sweep as the net, covered by the P1 sweep test).
|
||||
|
||||
**Success criteria**:
|
||||
- Both images build on the host via the extended deploy.sh; staged smoke suite passes all four assertions (401 / healthz 200 / live-mode marker / SSE unbuffered).
|
||||
- A keyed user on the staged instance gets fully real inference end to end through the proxy (`resolved_tier="user"` visible in the authenticated UI).
|
||||
- Image-layer secrets sweep clean; no auth-disable path exists in the production image.
|
||||
- Gates green; patch v0.3.4 shipped.
|
||||
|
||||
## Phase 5 — Final Review + Ship (FINAL — not planned in detail here)
|
||||
|
||||
Per ROADMAP: multi-persona review of the full milestone diff (REQ-69..85), audit (reconstruction, secrets sweep, branch/tag hygiene), then tag **v0.3.5 = the milestone release**. The pipeline HALTS at the merge_to_main owner approval gate (D-018 carries); deployment to research.coreci.dev proceeds only on owner directive (D-019 carries). The security-engineer's removal note (deactivate after P4) executes at P4 ship; the P5 review runs the standard full multi-persona pass. Detailed planning lands as the P5 PLAN when P4 completes, per the v0.2 precedent.
|
||||
|
||||
## Cross-Phase Notes
|
||||
|
||||
- **Versioning**: patches v0.3.1 (P1) → v0.3.2 (P2) → v0.3.3 (P3) → v0.3.4 (P4) → v0.3.5 (P5, the milestone release); tags on the v0.3.x line; MAJOR stays 0.
|
||||
- **Carrying gates**: G-005 snapshot discipline applies in every route-touching phase (P1, P2, P3); G-007 cost counters survive as owner-keyed (P-13, P2); G-008 DDG fixture discipline carries untouched; D-018/D-019 halt + deploy gates bind at P4/P5.
|
||||
- **REQ coverage assert**: each of REQ-69..85 maps to ≥1 test or contract proof in its phase (auth suite, key-store tamper suite, read-model suite, hosted smoke) — the audit reconstructs the milestone from these.
|
||||
- **Interface additions**: the C3-18 list is exhaustive; any other ApiClient change requires a PLAN amendment before implementation (the G-002 lesson, restated).
|
||||
- **Pitfall ledger (C3-19)**: P-1, P-2, P-13 close in P2; P-4, P-5, P-6, P-7, P-9, P-10, P-11, P-14 + P-8 (auth half) close in P1; P-8 (full) + P-12 close in P3; P-3 closes across P2 (backend half) and P4 (hosting half). All fourteen verified closed by v0.3.4.
|
||||
- **Engine contracts unchanged throughout**: the only engine-file touches in the milestone are additive — `RunState.owner_id` stamping (P1) and the `_verify_phase` artifact writes (P3, D-113); no methodology, state-machine, or protocol rewrites anywhere.
|
||||
+27
-8
@@ -6,9 +6,10 @@ ResearchX (working name; rename deferred — naming collision audit on file, see
|
||||
## Core Value
|
||||
A deep-research studio where you bring your own model, storage, and memory; choose a research methodology before a single query fires; and every claim in the final report is anchored to a visited, verified, quotable source. Simple enough to run in three clicks, transparent enough to audit line by line.
|
||||
|
||||
## Objective (two-milestone plan, owner-directed 2026-09-27)
|
||||
1. **Milestone v0.1 — Prototype**: UI/UX only, mock data, the production Next.js frontend with all data behind a typed `ApiClient` interface backed by `MockApiClient`. Surfaces: Ask, Choose (all 8 methodology cards), Plan & Run, Reading Room v0, v2 surface mockups (project graph, task inbox). **Ships only after owner approval — no MVP work begins until approved.**
|
||||
2. **Milestone v0.2 — MVP**: gated on prototype approval. FastAPI backend, custom async engine, adapter layer, trust loop, and integration (MockApiClient swapped for the OpenAPI-generated client).
|
||||
## Objective (three-milestone plan, owner-directed; v0.3 scope directed 2026-09-29)
|
||||
1. **Milestone v0.1 — Prototype** (COMPLETE, approved 2026-09-27, v0.1.8): UI/UX with mock data — production Next.js frontend behind a typed `ApiClient`/`MockApiClient` boundary.
|
||||
2. **Milestone v0.2 — MVP** (COMPLETE, approved 2026-09-27, v0.2.5): FastAPI backend, async engine, adapter layer, trust loop, OpenAPI client swap. Live mode = core research loop only (G-002/G-009 ratified).
|
||||
3. **Milestone v0.3 — Hosted** (owner-directed 2026-09-29): the hosted multi-user product — auth + sessions (user/pass, admin-managed accounts, OAuth deferred), per-user settings incl. model API keys (encrypted at rest; resolution chain user key → server key → keyless localhost), all prototype surfaces live-bound and user-scoped (library, settings persistence, suggestions, refine, graph/inbox, profile), backend hosting for the LXC deployment (SSE-safe), and real inference as the product default (mock demoted to dev/test fixture). The remaining 6 methodologies stay v0.4+ scope.
|
||||
|
||||
## Requirements (validated)
|
||||
1. Five pluggable adapter slots: model (OpenAI-compatible, planner/writer split), storage (local FS), search (Tavily default, DuckDuckGo keyless fallback), memory (off — stubbed), secrets (env — OpenBao later)
|
||||
@@ -28,16 +29,18 @@ A deep-research studio where you bring your own model, storage, and memory; choo
|
||||
- Memory scopes (semantic caching), S3/git storage adapters, Exa/Perplexity/Google search, OpenBao secrets, Docker packaging
|
||||
|
||||
## Constraints
|
||||
- Prototype must be approved before MVP work begins (owner directive)
|
||||
- ~~Prototype must be approved before MVP work begins~~ (v0.1 approved 2026-09-27; v0.2 approved 2026-09-27)
|
||||
- Versioning starts at v0.1; MAJOR remains 0 until an owner-approved v1 release; all pre-v1 ships are MINOR/PATCH only (owner directive)
|
||||
- ~~Single-user, local-first tool — no auth/multi-tenancy in MVP~~ **SUPERSEDED v0.3 (owner-directed 2026-09-29)**: multi-user hosted product — user/pass auth, admin-managed accounts (first registered user is admin), OAuth deferred to v0.4+; runs/library/settings partitioned by user
|
||||
- Per-user model keys resolve per run: user's own key → server env-file key → keyless localhost fallback (owner directive)
|
||||
- Not a general-purpose chatbot; not a model host; not a closed content index (v1)
|
||||
- Paywalled academic access out of scope v1 — open APIs (ArXiv/PubMed) only
|
||||
- Single-user, local-first tool — no auth/multi-tenancy in MVP
|
||||
- Ollama/vLLM supported via OpenAI-compatible endpoints only (no vendor-specific adapters)
|
||||
- Secrets never in app config or git; model capability honesty (warn when selected model is weak at structured outputs)
|
||||
- No deploys without owner directive (D-019 carries); milestone merge to main halts at the owner approval gate (D-018 carries)
|
||||
|
||||
## Out of Scope
|
||||
- Paywalled academic sources; model hosting; multi-tenant/auth; native Ollama API adapter; domain registration/rename decision
|
||||
- Paywalled academic sources; model hosting; open self-serve registration / OAuth (admin-managed accounts only, OAuth v0.4+); native Ollama API adapter; domain registration/rename decision
|
||||
|
||||
## Context
|
||||
Vision finalized and locked 2026-09-26: v1 methodology composition LOCKED (8 core), product named ResearchX, OpenBao retained as architectural secrets component, criticism matrix and evidence base [E1]–[E15] on file. Owner revisions 2026-09-27: (1) prototype-first — UI/UX prototype with mock data must be approved before MVP work begins; (2) version starts at v0.1, MAJOR remains 0 until MVP released; (3) the prototype is its own milestone. Milestone plan: v0.1 prototype (this milestone) → v0.2 MVP (gated).
|
||||
@@ -51,5 +54,21 @@ Vision finalized and locked 2026-09-26: v1 methodology composition LOCKED (8 cor
|
||||
- Model adapter default: Ollama cloud-first (ollama.com/v1) with keyless localhost:11434/v1 fallback on auth/connection failure
|
||||
- Prototype-first gate (owner): UI/UX prototype with mock data, approved before MVP work; prototype is its own milestone (v0.1)
|
||||
- Versioning (owner): starts at v0.1; MAJOR locked at 0 until owner-approved v1 release; MVP may span multiple 0.x minor milestones
|
||||
- Prototype = production Next.js frontend with typed ApiClient/MockApiClient split — zero throwaway work; phase 4 of v0.2 swaps the mock for the OpenAPI client
|
||||
- Tag-line mapping: 0.x milestones run patches on their own minor line (v0.1.x); supersedes the greenfield deviation noted at init
|
||||
- Prototype = production Next.js frontend with typed ApiClient/MockApiClient split — zero throwaway work; phase 3 of v0.2 swaps the mock for the OpenAPI client
|
||||
- Tag-line mapping: 0.x milestones run patches on their own minor line (v0.1.x); supersedes the greenfield deviation noted at init
|
||||
|
||||
## Key Decisions (v0.2 clarify — D-007..D-020, 2026-09-27)
|
||||
- Backend layout: `backend/` repo-root package, src-layout `researchx` (config, methodologies, adapters, engine, evidence, trust, api per ARCHITECTURE §Components); uv + uv-managed Python 3.12 (D-007/D-008)
|
||||
- Ports/binding: backend 127.0.0.1:8000, frontend :3000 via NEXT_PUBLIC_API_BASE_URL; CORS localhost:3000 (D-009); run IDs = 8-char slugs; storage = <dir>/runs/<id>/ (D-010 — **superseded by D-106 for v0.3+: runs/<user_id>/<run_id>/**)
|
||||
- Adapters: Ollama base URLs + key via env-file secrets scopes (OLLAMA_BASE_URL, OLLAMA_API_KEY); DDG keyless fallback = html.duckduckgo.com/html conservative scrape (D-011/D-012)
|
||||
- Integration: openapi-typescript + openapi-fetch → frontend/src/lib/api/generated, bound behind ApiClient; RESEARCHX_API_MODE=mock|live, mock default (D-013/D-020)
|
||||
- Transport/trust: sse-starlette SSE + pydantic typed event bus (D-014); difflib quote anchoring ≥ 0.85 (D-015); HTTP liveness live/stale/dead (D-016); JSON durable state, atomic rewrite per transition (D-017)
|
||||
- Run termination: pipeline ships P1..P4 + P5 final patch, then HALTS at the owner approval gate before milestone → main (merge_to_main hook, v0.1 precedent); no deploys without owner directive (D-018/D-019)
|
||||
## Key Decisions (v0.3 clarify — D-101..D-115, 2026-09-29)
|
||||
- Sessions: server-side store (in-process dict + durable sessions.json, D-017 idiom), httpOnly SameSite=Lax cookies, opaque tokens, 14-day sliding expiry, immediate revocation (D-101); argon2id via argon2-cffi, verified via asyncio.to_thread (D-102)
|
||||
- Auth posture: 401 on all /api/* except login/register (register open only while user store empty — first user is admin, then admin-managed; D-103/D-104); CSRF = SameSite=Lax + custom header on non-GET (EventSource GET exempt); /healthz outside /api for proxy checks
|
||||
- Identity/partitioning: user_id = 8-char slug, email login (D-105); storage runs/<user_id>/<run_id>/ (D-106 supersedes D-010); data root = RESEARCHX_DATA_DIR (backend/var/researchx dev, /var/lib/researchx container; D-107)
|
||||
- Key store: AES-256-GCM with RESEARCHX_KEK from the env-file 'keys' scope, user_id as AAD; disabled honestly when no KEK (D-108); scope = model key only (D-109); model adapter constructed per run (fixes shared-adapter fallback bug; D-110)
|
||||
- Settings: GET /api/config stays read-only adapter status (user-contextualized resolved tier); new GET/PUT /api/config/settings for per-user settings; storage path display-only hosted (D-111)
|
||||
- Mock fate: code default stays mock (zero-key dev/CI); hosted image built with NEXT_PUBLIC_RESEARCHX_API_MODE=live; no live delegation to mock after P3 (D-112)
|
||||
- REQ-82 is backend work: engine persists report.json + evidence.json at DONE; endpoints enriched (D-113); open questions = plan sub-questions answered-iff DONE (D-114); read-models read disk artifacts, not RunRegistry; in-flight runs marked cancelled on restart; rehydration out of v0.3 scope (D-115)
|
||||
|
||||
@@ -73,6 +73,40 @@
|
||||
- **REQ-33**: Monorepo: uv-managed backend (ruff, mypy, pytest) + Next.js frontend (ESLint, strict TS, Vitest); one-command install/dev
|
||||
- **REQ-34**: Full export round-trip: report + ledger + citations survive as real links
|
||||
|
||||
### Clarified constraints (v0.2 P0 clarify — D-007..D-020)
|
||||
- **D-007/D-008**: backend/ src-layout package `researchx` (config, methodologies, adapters, engine, evidence, trust, api); uv + managed Python 3.12
|
||||
- **D-009/D-010**: backend 127.0.0.1:8000, CORS localhost:3000; run IDs 8-char slugs; runs/<id>/ storage layout
|
||||
- **D-011/D-012**: model/search endpoints + keys resolve from env-file secrets scopes; DDG keyless fallback via html.duckduckgo.com/html scrape
|
||||
- **D-013/D-020**: openapi-typescript + openapi-fetch generated client bound behind ApiClient; RESEARCHX_API_MODE=mock|live, mock default until P3
|
||||
- **D-014..D-017**: sse-starlette SSE + pydantic event bus; difflib quote anchoring ≥ 0.85; HTTP liveness live/stale/dead; JSON durable state atomically rewritten per transition
|
||||
|
||||
## Hosted (Milestone v0.3 — owner-directed 2026-09-29)
|
||||
|
||||
### Auth & Sessions
|
||||
- **REQ-69**: User/pass auth: user model with argon2id password hashing (argon2-cffi, verified off the event loop), register/login/logout/me routes, httpOnly cookie sessions (server-side session store: in-process dict + durable JSON under the data root, immediate revocation on logout/deactivation), CSRF-safe same-origin posture (SameSite=Lax cookie + custom-header check on mutating requests); OAuth explicitly deferred to v0.4+
|
||||
- **REQ-70**: Admin-managed accounts: registration is open only while the user store is empty; the first registered user becomes admin; subsequently accounts are created by an admin only (admin-set initial password, no email infrastructure); admin can list/create/deactivate accounts (deactivation kills sessions; data retained)
|
||||
- **REQ-71**: User scoping: every run, stored report, and setting is partitioned by owner — the engine, storage paths, and library read-model are user-scoped; storage layout becomes runs/<user_id>/<run_id>/ (supersedes D-010); owner_id is recorded in run state; no migration (pre-hosted artifacts disposable); no user can read or act on another user's data through the API
|
||||
|
||||
### Per-User Settings & Model Keys
|
||||
- **REQ-72**: Per-user settings persistence (PUT/GET /api/config/settings): methodology defaults, resolved dials, appearance slot, masked key status; storage directory is server-managed per-user — display-only in hosted mode (the free-path picker applies to the local single-user profile only)
|
||||
- **REQ-73**: Per-user model API keys: masked entry in Settings (never returned in full — masked status only), encrypted at rest (per-user key store; KEK = RESEARCHX_KEK from the env-file secrets 'keys' scope; cipher AES-256-GCM with user_id as AAD; without a configured KEK the key store is disabled with honest status — chain falls to server/keyless); scope = model key (OLLAMA_API_KEY) only in v0.3
|
||||
- **REQ-74**: Per-run model credential resolution chain: user's own key → server env-file key → keyless localhost:11434 fallback (existing fallback semantics preserved); the model adapter is constructed per run (fixes the shared-adapter fallback bug); the capability probe runs against the resolved tier; config status surfaces which tier resolved (user|server|keyless) for the current user
|
||||
- **REQ-75**: Real inference as the product default: the code default of RESEARCHX_API_MODE stays mock (zero-key dev/CI per REQ-31); the hosted frontend is built with NEXT_PUBLIC_RESEARCHX_API_MODE=live (build-time inlining); after v0.3 P3 no live-mode surface delegates to the mock; mock demoted to a dev/test fixture
|
||||
|
||||
### Live Surfaces (G-002 boundary lifted, user-scoped)
|
||||
- **REQ-76**: Library backend: GET /api/library — folder-structure listing over the user's runs (auto-categorization by topic + methodology, recency/title sorting); report cards resolve to real runs
|
||||
- **REQ-77**: Cross-report connections + project graph: GET /api/library/connections and GET /api/graph — shared-source and topical edges computed from the user's ledgers and run metadata
|
||||
- **REQ-78**: Task inbox: GET /api/tasks — open questions from the user's runs as checkable follow-ups; open questions = plan sub-questions of the user's runs, answered-iff the run reached DONE
|
||||
- **REQ-79**: Suggestions: GET /api/suggestions — follow-up query suggestions derived from the user's unanswered questions (open inbox items joined with source runs); click-fills-the-box semantics preserved; empty state for users with no runs (no synthetic suggestions)
|
||||
- **REQ-80**: Refine: POST /api/refine/questions + /api/refine/compose — planner-model-driven clarifying exchange composing a refined question + suggested context (scripted prototype dialogue replaced by real model calls)
|
||||
- **REQ-81**: User profile: GET/PUT /api/profile — local profile (display name, email) surfaced in the avatar user menu; no account deletion in v0.3
|
||||
- **REQ-82**: Live Reading Room fidelity: the live report renders structured sections with page-anchored citations (hover shows the stored quoted passage) and the ledger panel from the citation ledger — parity with the mock Reading Room v0; the engine persists report.json (structured sections + anchored citations with quotes) and evidence.json (passage texts) at DONE; report/ledger endpoints expose them; live exports use real entry data (no fabricated fields)
|
||||
|
||||
### Hosting
|
||||
- **REQ-83**: Backend container: Dockerfile for the FastAPI backend (single uvicorn worker, non-root), two-service deployment (frontend + backend) via deploy.sh extension; RESEARCHX_DATA_DIR volume (default /var/lib/researchx); unauthenticated /healthz outside /api for proxy health checks
|
||||
- **REQ-84**: SSE-safe reverse proxy: HAProxy path routing /api → backend with streaming timeouts that do not buffer or cut long-lived SSE connections; frontend served with NEXT_PUBLIC_API_BASE_URL pointing at the public origin
|
||||
- **REQ-85**: Hosted live mode: the deployed instance runs real inference in live mode (per REQ-75), per-user keys honored; auth guard covers the backend surface (no unauthenticated API access through the public URL)
|
||||
|
||||
## V2 (Post-MVP backlog)
|
||||
|
||||
### Methodology Catalog Extension
|
||||
@@ -105,7 +139,7 @@
|
||||
|---|---|
|
||||
| Paywalled academic access | Unsolved in v1 — routed around via open APIs (vision §8) |
|
||||
| General-purpose chatbot / model host / closed content index | Explicit non-goals (vision §8) |
|
||||
| Auth / multi-tenancy | Single-user local-first MVP |
|
||||
| Auth / open self-serve registration / OAuth | v0.3 ships user/pass auth with admin-managed accounts (owner-directed 2026-09-29); open registration and OAuth stay out (OAuth v0.4+) |
|
||||
| Native Ollama API adapter | Ollama supported via OpenAI-compatible endpoint only |
|
||||
| Domain registration / product rename | Deferred by owner (vision §11) |
|
||||
| v1.0.0 release | Owner-approved release only; not created autonomously by the pipeline |
|
||||
@@ -122,9 +156,13 @@
|
||||
| REQ-62 (approval gate) | v0.1 | P8 | complete (owner approval recorded 2026-09-27) |
|
||||
| REQ-63, REQ-64, REQ-65, REQ-66 (app shell, library, settings, avatar) | v0.1 | P5 | complete |
|
||||
| REQ-67, REQ-68 (ask suggestions, refine dialogue) | v0.1 | P7 | complete |
|
||||
| REQ-01..06, REQ-08..11, REQ-31 (framework, adapters) | v0.2 | P1 | blocked |
|
||||
| REQ-07, REQ-12, REQ-13..24 (probing, engine, trust) | v0.2 | P2 | blocked |
|
||||
| REQ-25, REQ-26, REQ-33 (API, client, tooling) | v0.2 | P3 | blocked |
|
||||
| REQ-17, REQ-32, REQ-34 (adversarial, E2E, round-trip) | v0.2 | P4 | blocked |
|
||||
| All REQ-01..34 (milestone gate) | v0.2 | P5 | blocked |
|
||||
| REQ-01..06, REQ-08..11, REQ-31 (framework, adapters) | v0.2 | P1 | complete (v0.2.1; REQ-07/12 contracts proven, live probing in P2) |
|
||||
| REQ-07, REQ-12, REQ-13..24 (probing, engine, trust) | v0.2 | P2 | complete (v0.2.2) |
|
||||
| REQ-25, REQ-26, REQ-33 (API, client, tooling) | v0.2 | P3 | complete (v0.2.3) |
|
||||
| REQ-17, REQ-32, REQ-34 (adversarial, E2E, round-trip) | v0.2 | P4 | complete (v0.2.4) |
|
||||
| All REQ-01..34 (milestone gate) | v0.2 | P5 | complete (v0.2.5 — **owner approval recorded 2026-09-27**, G-009 ratified; milestone merged to main) |
|
||||
| REQ-69..71 (auth, sessions, scoping) | v0.3 | P1 | complete (v0.3.1; security review P0 register race + P1 corrupt-store fail-closed fixed in-phase; verify PASS 4/4) |
|
||||
| REQ-72..74 (settings, per-user keys, resolution chain) | v0.3 | P2 | complete (v0.3.2; P0 null-poisoning fixed in verify; P-1/P-2/P-13 closed; production main entrypoint P-3 backend half) |
|
||||
| REQ-76..82 (live surfaces, user-scoped) | v0.3 | P3 | pending |
|
||||
| REQ-75 (delegation-end half) + REQ-83..85 (hosting; live default hosted) | v0.3 | P3/P4 (REQ-75 split per G-103: delegation-end P3, hosted-default P4) | pending |
|
||||
| REQ-35..51 | v2 backlog (post-MVP milestones) | — | pending |
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
# Research — v0.2 MVP (Phase 0)
|
||||
|
||||
## Run: 2026-09-27 (stage: research)
|
||||
|
||||
Phase-0 research findings for milestone v0.2 (MVP): FastAPI backend, custom asyncio engine, adapter layer, trust loop, OpenAPI client integration. Each topic records concrete recommendations and pitfalls that PLAN consumes.
|
||||
|
||||
## 1. Backend foundation (uv + FastAPI)
|
||||
|
||||
**Recommendation:**
|
||||
- uv (>= 0.4) with `backend/pyproject.toml`, src-layout: `backend/src/researchx/__init__.py`, `backend/src/researchx/{config,methodologies,adapters,engine,evidence,trust,api}/__init__.py`
|
||||
- `[project] name = "researchx"`, `requires-python = ">=3.12"`; deps: `fastapi>=0.115,<0.116`, `pydantic>=2.8,<3`, `uvicorn>=0.30,<1`, `sse-starlette>=2.1,<3`, `httpx>=0.27,<1`
|
||||
- Dev group (`uv sync --group dev` or `[dependency-groups]`): `ruff>=0.6`, `mypy>=1.11`, `pytest>=8`, `pytest-asyncio>=0.23`
|
||||
- `[build-system] hatchling`; `[tool.hatch.build.targets.wheel] packages = ["src/researchx"]` — src-layout pitfall: without the explicit packages key, uv/hatch builds fail or ship an empty wheel
|
||||
- Dev run: `cd backend && uv run uvicorn researchx.api.app:app --reload --port 8000`; one-command from root documented in README
|
||||
- mypy strict on `src/`: `[tool.mypy] strict = true, mypy_path = "src"`; ruff: line-length 100, target py312
|
||||
|
||||
**Pitfalls:**
|
||||
- `uv run` resolves the project venv automatically — never `pip install` into it
|
||||
- pytest needs `pythonpath = ["src"]` in `[tool.pytest.ini_options]` or an editable install (`uv sync` does this) — tests import `researchx.*` directly
|
||||
- pytest-asyncio: use `asyncio_mode = "auto"` and `loop_scope = "function"` (the old `event_loop` fixture is removed in recent versions)
|
||||
|
||||
## 2. Ollama OpenAI-compatible endpoints
|
||||
|
||||
**Recommendation:**
|
||||
- Cloud: `https://ollama.com/v1` with `Authorization: Bearer <OLLAMA_API_KEY>`; local fallback `http://localhost:11434/v1` keyless
|
||||
- Chat: POST `/chat/completions` (OpenAI shape: messages, model, temperature; response choices[0].message.content)
|
||||
- Probe: GET `/models` → data[].id; capability check via a tiny structured-completion smoke test at connect time
|
||||
- Context cap: `min(model_claim, 8192)` tokens; model claim unknown → 8192 flat; embedding batch 8
|
||||
- Planner/writer roles = two model names from settings (default: same model both roles)
|
||||
|
||||
**Pitfalls:**
|
||||
- ollama.com/v1 is cloud-keyed — 401 on missing/invalid key → automatic local-fallback attempt, then surfaced error
|
||||
- Some served models don't support function calling/structured outputs → REQ-12 warning path: detect via smoke-test failure, warn in UI, degrade to plain chat + prompt-embedded JSON contract
|
||||
- Model names are server-specific (`q4` suffixes etc.) — settings must store the exact served name; probe lists what's actually available
|
||||
|
||||
## 3. Search adapters
|
||||
|
||||
**Recommendation:**
|
||||
- Tavily: POST `https://api.tavily.com/search`, JSON `{"api_key": key, "query": q, "search_depth": "basic"|"advanced", "max_results": n, "include_answer": false}` → `results[]: {title, url, content, score}`; timeouts 10s
|
||||
- DuckDuckGo keyless fallback: POST `https://html.duckduckgo.com/html/?q=<urlencoded>` with a plain httpx client; parse anchors `a.result__a` (href → URL, text → title) and `a.result__snippet` from the result list; conservative: max 8 results, no retries beyond one backoff, user-agent string honest
|
||||
- Adapter interface: `async search(query, max_results) -> list[SearchResult]`; registry selects Tavily when TAVILY_API_KEY present, else DDG
|
||||
|
||||
**Pitfalls:**
|
||||
- DDG html rate-limits aggressively — the fallback is best-effort; on 403/timeout return partial/empty with a surfaced warning, never hang the engine
|
||||
- Tavily's `content` is already a snippet — evidence extraction still fetches the live page (visited-only constraint requires the visit)
|
||||
- Never put the API key in a query string or log line
|
||||
|
||||
## 4. FastAPI SSE + asyncio durability
|
||||
|
||||
**Recommendation:**
|
||||
- `sse-starlette` EventSourceResponse fed by a per-run `asyncio.Queue`; pydantic event models with a discriminated union on `type` (run_started, query_fired, source_visited, passage_extracted, state_changed, run_done, run_error)
|
||||
- EventSourceResponse generator: `async for event in queue` → `{"event": <type>, "data": <model_dump_json>}`; send on connect a `state_changed` replay of current persisted state
|
||||
- Client disconnect: wrap the generator in try/finally that cancels only the SSE subscription (never the run); runs keep going when browsers disconnect
|
||||
- Backpressure: unbounded queue per run is acceptable single-user; cap at 1000 with drop-oldest `state_changed` coalescing if needed
|
||||
|
||||
**Pitfalls:**
|
||||
- No blocking I/O in the response generator — all fetches via httpx.AsyncClient
|
||||
- uvicorn single worker; multiple workers would break in-memory run registry — document single-user explicitly
|
||||
- On reconnect (Last-Event-ID unused in MVP) → replay current state + live events onward; full event log persisted in the run dir anyway
|
||||
|
||||
## 5. Durable state machine patterns
|
||||
|
||||
**Recommendation:**
|
||||
- `runs/<id>/state.json`: `{run_id, state, dial_values, plan, question, created_at, updated_at, cursor}` — rewritten atomically after every transition: write `.tmp`, `os.replace`
|
||||
- Transitions only at method boundaries of the state machine; the cursor records depth-loop position for resume
|
||||
- Pause = asyncio.Event cleared; cancel = task.cancel() + terminal CANCELLED state persisted; resume on restart = state file read, EventBus rebuilt, loop re-entered at cursor
|
||||
- pytest-asyncio: `asyncio_mode = "auto"`; tests construct the engine with fake adapters and `await engine.run()` directly
|
||||
|
||||
**Pitfalls:**
|
||||
- Never swallow CancelledError — re-raise after persisting the cancel state
|
||||
- Shield the state write: `with contextlib.suppress(asyncio.CancelledError): await asyncio.shield(save())` then re-raise
|
||||
- Torn reads: only ever `os.replace` (atomic on same filesystem); never append — state is a snapshot, trace is a separate append-only file
|
||||
|
||||
## 5b. Testing fakes (REQ-31)
|
||||
|
||||
**Recommendation:**
|
||||
- FakeModelAdapter: canned planner (sub-questions from a fixture), canned writer (deterministic report citing fixture passage IDs) — zero API keys
|
||||
- FakeSearchAdapter: fixture corpus of 5-8 pages with overlapping passages; deterministic ordering
|
||||
- Engine tests drive every transition, depth loop, pause/resume mid-depth-loop, cancel, and adversarial writer outputs
|
||||
|
||||
**Pitfalls:**
|
||||
- Fakes must implement the full adapter interface (no stub methods) — verify layer asserts interface conformance
|
||||
- Keep fixtures small and unicode-diverse (quote anchoring edge cases)
|
||||
|
||||
## 6. §6. Quote anchoring + liveness
|
||||
|
||||
**Recommendation:**
|
||||
- Normalize (unicode NFC → casefold → whitespace collapse) both quote and passage; exact-substring short-circuit; else difflib SequenceMatcher ratio ≥ 0.85
|
||||
- Ledger entry: `{url, visited_at, passages: [{id, quote}], claims: [claim_ids], health, authority_tier}`
|
||||
- Liveness: single GET with redirect-follow (httpx, 10s timeout): 2xx live; 3xx loop/parked-domain or content-gone markers → stale; 4xx/5xx/timeout/unresolvable → dead
|
||||
- ≥99% liveness gate: shipped report citations must pass liveness at export time; failures listed in the verification checklist
|
||||
|
||||
**Pitlingfalls:**
|
||||
- SequenceMatcher on long passages is O(n²)-ish — anchor per-passage (passages are ≤ ~500 chars), not per-document
|
||||
- Fuzzy matches near threshold (0.80-0.90) flagged in the ledger as `weak_match` for transparency
|
||||
|
||||
**Pitfalls:** (corrected)
|
||||
- SequenceMatcher cost is quadratic-ish — always match quote vs its claimed passage only
|
||||
- Near-threshold (0.80–0.90) matches flagged `weak_match` in the ledger — surfaced, not silently accepted
|
||||
|
||||
## 7. OpenAPI client generation
|
||||
|
||||
**Recommendation:**
|
||||
- `openapi-typescript@^7` (types) + `openapi-fetch@^0.13` (typed fetch client), generated into `frontend/src/lib/api/generated/`
|
||||
- Regen: `pnpm dlx openapi-typescript http://127.0.0.1:8000/openapi.json -o frontend/src/lib/api/generated/schema.d.ts` — needs the backend running, or read a committed `openapi.json` snapshot in CI
|
||||
- Binding: a thin `GeneratedApiClient` implements the existing ApiClient interface by delegating to openapi-fetch with baseURL from NEXT_PUBLIC_API_BASE_URL; components never import it directly — only the boundary file does
|
||||
- RESEARCHX_API_MODE=mock|live env flag selects the binding at boundary initialization; mock stays default
|
||||
|
||||
**Pitfalls:**
|
||||
- FastAPI operation-id stability: route order and function names determine ids — fix function names early; regen discipline = regenerate only from a committed snapshot
|
||||
- FastAPI's default unions (None | X) — openapi-typescript emits optional/nullable types that must be handled at the boundary, not in components
|
||||
|
||||
## 8. Monorepo ergonomics
|
||||
|
||||
**Recommendation:**
|
||||
- Root README one-command story: `uv sync` (backend) + `corepack pnpm install` (frontend); dev: `uv run uvicorn ...` + `corepack pnpm dev`
|
||||
- Backend gates: `uv run ruff check src tests`, `uv run mypy src`, `uv run pytest` (all via `uv run` from backend/)
|
||||
- Frontend gates carry over from v0.1 unchanged: `pnpm typecheck`, `pnpm lint`, `pnpm test`, `pnpm build`
|
||||
- Repo-root package.json is NOT created (frontend is the only JS package); document commands in README instead
|
||||
|
||||
**Pitfalls:**
|
||||
- pnpm via corepack (packageManager field pins pnpm@12.6.0) — direct pnpm may be absent; always `corepack pnpm`
|
||||
- openapi.json snapshot committed for reproducible client regen; regenerate the TS client when the snapshot changes
|
||||
|
||||
## Research Conclusions
|
||||
|
||||
1. Backend = uv + src-layout `researchx` in backend/, pyproject with hatchling `packages=["src/researchx"]`, deps fastapi/pydantic/uvicorn/sse-starlette/httpx, dev ruff/mypy/pytest/pytest-asyncio — one-command via `uv sync && uv run uvicorn researchx.api.app:app`.
|
||||
2. All I/O lives in adapters; engine imports no vendor SDKs (httpx only inside adapters).
|
||||
3. Ollama cloud `https://ollama.com/v1` (Bearer) → local `http://localhost:11434/v1` keyless fallback on 401/connection failure; probe via GET /models + structured-output smoke test; context cap min(claim, 8192), embed batch 8.
|
||||
4. REQ-12 structured-output warning: detect via smoke test; degrade to prompt-embedded JSON contract + UI warning — never fail cryptically.
|
||||
5. Tavily POST api.tavily.com/search with api_key in JSON body; DDG fallback scrapes html.duckduckgo.com/html anchors conservatively (max 8, one backoff, surfaced warnings on rate-limit).
|
||||
6. SSE = sse-starlette EventSourceResponse + per-run asyncio.Queue + pydantic discriminated-union event types; state replay on connect; single uvicorn worker (single-user).
|
||||
7. Durable state = runs/<id>/state.json, atomic tmp+os.replace per transition, cursor for depth-loop resume; trace is a separate append-only file; shielded state writes; never swallow CancelledError.
|
||||
8. Quote anchoring = normalize + exact-substring short-circuit + SequenceMatcher ≥ 0.85 per-passage; near-threshold flagged weak_match.
|
||||
9. Liveness = redirect-following GET, 10s: 2xx live / redirect-loop or content-gone stale / 4xx-5xx-timeout dead; ≥99% citation liveness gate at export.
|
||||
10. Fake adapters (REQ-31) implement the full adapter interface with canned planner/writer + fixture corpus — enables the entire engine + trust test suite with zero keys.
|
||||
11. Client gen = openapi-typescript@^7 + openapi-fetch@^0.13 from a committed openapi.json snapshot; GeneratedApiClient binds behind the ApiClient interface; RESEARCHX_API_MODE=mock|live selects binding; mock default.
|
||||
12. Monorepo gates: backend `uv run ruff check`, `uv run mypy`, `uv run pytest`; frontend v0.1 gates unchanged; no root package.json — commands documented in README.
|
||||
13. Config/status endpoints surface probe results + adapter status (REQ-25 GET /api/config) — plan the schema early so the settings surface stays honest.
|
||||
14. All decisions D-007..D-020 confirmed workable; refinements: hatchling packages key (new), openapi.json snapshot discipline (new), single-worker uvicorn note (new), structured-output smoke-test detection pattern (new) — folded into PLAN as constraints.
|
||||
@@ -0,0 +1,555 @@
|
||||
# Research — v0.3 Hosted (Phase 0)
|
||||
|
||||
## Run: 2026-09-29 (stage: research)
|
||||
|
||||
Phase-0 research findings for milestone v0.3 (Hosted): auth + server-side
|
||||
sessions, per-user encrypted model keys, user-scoped storage, live surfaces
|
||||
backed by disk artifacts, and two-service hosting behind an SSE-safe proxy.
|
||||
Each topic records concrete code/config shapes and pitfalls that PLAN
|
||||
consumes. All library claims were verified against the pinned versions in
|
||||
the repo (starlette 0.46.2, fastapi 0.115.14, sse-starlette, eventsource
|
||||
polyfill 5.1.2).
|
||||
|
||||
## 1. FastAPI auth/session layer
|
||||
|
||||
**Recommendation:**
|
||||
- New package `backend/src/researchx/auth/`: `users.py` (user model +
|
||||
argon2id), `sessions.py` (server-side session store), `guard.py`
|
||||
(session + CSRF dependency), `routes.py` (register/login/logout/me).
|
||||
- **Route dependencies, not middleware, as the enforcement point.** Group
|
||||
all protected routes under `APIRouter(dependencies=[Depends(require_user)])`
|
||||
inside `create_app`. The dependency resolves the session cookie, loads
|
||||
the user, runs the CSRF header check on non-GET, and returns the `User`:
|
||||
```python
|
||||
async def require_user(request: Request) -> User:
|
||||
token = request.cookies.get(SESSION_COOKIE) # "researchx_session"
|
||||
session = sessions.get(token) # None if absent/expired/revoked
|
||||
if session is None:
|
||||
raise HTTPException(status_code=401, detail="authentication required")
|
||||
if request.method != "GET" and "x-researchx-request" not in request.headers:
|
||||
raise HTTPException(status_code=403, detail="missing request header")
|
||||
user = users.get(session.user_id)
|
||||
if user is None or not user.active:
|
||||
raise HTTPException(status_code=401, detail="account disabled")
|
||||
sessions.touch(session) # sliding expiry
|
||||
return user
|
||||
```
|
||||
Rationale vs global middleware: the dependency appears in OpenAPI (401
|
||||
documented), is overridable per-test via `app.dependency_overrides`
|
||||
(critical for the zero-key harness, topic 9), and avoids the pure-ASGI
|
||||
middleware rewrite needed to keep SSE streaming safe. A public router
|
||||
(`/api/auth/*`, `/healthz`) + protected router (everything else)
|
||||
enforces "all /api/* except login/register" by construction.
|
||||
- **Session store (D-101):** opaque token = `secrets.token_urlsafe(32)`;
|
||||
in-process `dict[token, Session]` + durable `sessions.json` at the data
|
||||
root, written with the atomic tmp+`os.replace` idiom (D-017).
|
||||
`Session = {token_hash, user_id, created_at, last_seen, expires_at}`.
|
||||
14-day sliding expiry via `touch()`; persist only when >60s since last
|
||||
persist. Immediate revocation = delete token + persist. Logout deletes
|
||||
the presented token; deactivation deletes all of the user's tokens.
|
||||
Rotate the token at login (kills session fixation by construction).
|
||||
- **Cookie:** `researchx_session=<token>`; `httponly=True, samesite="lax",
|
||||
path="/"`, `secure=True` only in hosted/TLS deploys (config flag), no
|
||||
`Domain` attribute (host-only).
|
||||
- **argon2-cffi (D-102):** `PasswordHasher()` defaults are argon2id with
|
||||
the RFC 9106 low-memory profile — keep the defaults. All calls off the
|
||||
event loop via `asyncio.to_thread`; `VerifyMismatchError` → uniform 401
|
||||
"invalid email or password"; `check_needs_rehash` → rehash on login.
|
||||
- **CORS ordering (verified):** the LAST-added middleware is OUTERMOST
|
||||
(starlette 0.46.2 `build_middleware_stack`). Add `CORSMiddleware` AFTER
|
||||
any auth middleware so preflight OPTIONS is answered without a 401.
|
||||
Cookie auth requires `allow_credentials=True` + explicit localhost
|
||||
origins (browsers reject wildcard origins with credentials).
|
||||
- **First-user-is-admin (D-104):** `POST /api/auth/register` checks
|
||||
`users.empty()` then inserts `role="admin"`; no `await` between check
|
||||
and insert except the argon2 hash — the single uvicorn worker makes
|
||||
this race-free by construction. After the first user, register 403s;
|
||||
`POST /api/admin/users` (admin-guarded) creates accounts.
|
||||
- **User store:** `users.json` at the data root:
|
||||
`{"users": [{"user_id", "email", "password_hash", "display_name",
|
||||
"role": "admin"|"user", "active": true, "created_at"}]}`; `user_id` =
|
||||
8-char slug from the run-ID alphabet + validation regex `^[a-z0-9]{8}$`
|
||||
(D-105 — filesystem-safe partition key).
|
||||
|
||||
**Pitfalls:**
|
||||
- Do NOT use `BaseHTTPMiddleware` for the guard — it wraps response bodies
|
||||
and has a history of SSE/streaming buffering bugs; router dependencies
|
||||
sidestep the class.
|
||||
- SameSite site-computation is scheme+registrable domain, NOT port —
|
||||
`localhost:3000 → localhost:8000` is same-site (Lax cookie flows with
|
||||
`credentials:"include"`), but `localhost:3000 → 127.0.0.1:8000` is
|
||||
CROSS-site (Lax cookie silently dropped). Dev docs must pin both sides
|
||||
to `localhost` (`NEXT_PUBLIC_API_BASE_URL=http://localhost:8000`).
|
||||
- argon2 verify at default params takes ~50–100ms CPU — `to_thread`
|
||||
mandatory for hash and verify.
|
||||
- `EnvFileSecretsProvider` loads at construction; user/session/key stores
|
||||
are mutable and must reload-or-rewrite atomically instead.
|
||||
|
||||
## 2. CSRF posture (D-103)
|
||||
|
||||
**Recommendation:**
|
||||
- Combined defense, no token round-trips: (1) `SameSite=Lax` session
|
||||
cookie blocks cross-site cookie attachment on background POSTs; (2)
|
||||
every non-GET `/api` request must carry `X-ResearchX-Request: 1`
|
||||
(checked in `require_user`). A cross-site attacker can neither attach
|
||||
the Lax cookie to a background POST nor set custom headers without
|
||||
CORS preflight approval.
|
||||
- Frontend: the live client sends the header on every mutating call —
|
||||
a small `#req()` wrapper or openapi-fetch middleware in
|
||||
`GeneratedApiClient`: `headers: { "X-ResearchX-Request": "1" }`.
|
||||
- **EventSource is exempt by design:** GET-only, cannot set custom
|
||||
headers — the guard skips GETs, so `/api/runs/{id}/events`
|
||||
authenticates by cookie alone. The eventsource polyfill (5.1.2) used
|
||||
in E2E supports `withCredentials` and a fetch override.
|
||||
- Login/register POSTs also require the header (uniform rule).
|
||||
- Dev split-origin: `fetch(..., {credentials: "include"})` everywhere +
|
||||
CORS `allow_credentials=True` + same-hostname origins (§1 pitfall).
|
||||
|
||||
**Pitfalls:**
|
||||
- If dev ever needs cross-SITE cookies (localhost vs 127.0.0.1), Lax
|
||||
fails silently — the fix is same-hostname origins, not `SameSite=None`.
|
||||
- `Secure` must NOT be set in plain-HTTP dev (browsers drop Secure
|
||||
cookies on http); MUST be set on the TLS-fronted host.
|
||||
|
||||
## 3. Per-user encrypted key store (D-108, D-109)
|
||||
|
||||
**Recommendation:**
|
||||
- New `backend/src/researchx/auth/key_store.py` using
|
||||
`cryptography.hazmat.primitives.ciphers.aead.AESGCM`
|
||||
(`AESGCM(key).encrypt(nonce, data, aad)` → ciphertext||16-byte tag;
|
||||
`decrypt` raises `InvalidTag` on mismatch; nonce = 12 bytes; NEVER
|
||||
reuse a nonce under the same KEK).
|
||||
- **KEK (D-108):** `RESEARCHX_KEK` from the env-file **'keys' scope**
|
||||
(`config.json secrets.scopes` gains `"keys": ["RESEARCHX_KEK"]`),
|
||||
accepted as urlsafe-base64 or hex of exactly 32 bytes; decode errors
|
||||
raise `AdapterError("RESEARCHX_KEK is not valid b64/hex")`; wrong
|
||||
length raises (`must decode to 32 bytes (AES-256)`).
|
||||
- **Envelope:** AAD = `user_id.encode()` (a copied file under another
|
||||
user's directory fails `InvalidTag`); fresh `os.urandom(12)` nonce per
|
||||
write; scope = `OLLAMA_API_KEY` only (D-109).
|
||||
- **File format** `keys/<user_id>.json` under the data root, atomic
|
||||
rewrite, `0600`:
|
||||
```json
|
||||
{
|
||||
"v": 1,
|
||||
"alg": "AES-256-GCM",
|
||||
"updated_at": "2026-09-29T12:00:00Z",
|
||||
"entries": {
|
||||
"OLLAMA_API_KEY": {"nonce": "<b64url(12)>", "ct": "<b64url(ct+tag)>", "updated_at": "..."}
|
||||
}
|
||||
}
|
||||
```
|
||||
- **API never returns key material** (REQ-73): PUT accepts a raw key, GET
|
||||
returns `{"configured": true, "masked": "…3f2a"}` (last 4 chars).
|
||||
Crypto in `asyncio.to_thread` for discipline.
|
||||
- Honest degradation: no KEK → PUT returns `{"key_store": "disabled"}`;
|
||||
resolution chain proceeds server/keyless; corrupt entries (InvalidTag)
|
||||
surface as error status, not silent tier drop.
|
||||
|
||||
**Pitfalls:**
|
||||
- GCM nonce reuse under a fixed KEK is catastrophic — always `os.urandom`.
|
||||
- KEK rotation is out of v0.3 scope; `v` + `entries` leave room later.
|
||||
- A changed KEK requires process restart (EnvFileSecretsProvider loads
|
||||
once) — document it.
|
||||
|
||||
## 4. Per-run model adapter factory (D-110)
|
||||
|
||||
**Recommendation:**
|
||||
- Verified shared-adapter bug (`ollama.py:73-79`): a cloud failure
|
||||
permanently flips `self._active_base` on the SHARED instance — every
|
||||
subsequent run by every user silently uses localhost. Fix = per-run
|
||||
adapter instances.
|
||||
- New `backend/src/researchx/adapters/model_factory.py`:
|
||||
`ModelAdapterFactory(secrets, key_store, defaults)` with
|
||||
`async resolve(user, settings) -> (OllamaModelAdapter, tier)` resolving
|
||||
the chain user → server → keyless (REQ-74) and constructing one
|
||||
adapter per run.
|
||||
- `OllamaModelAdapter` constructor gains optional `api_key: str | None`
|
||||
taking precedence over `secrets.resolve(...)`; the secrets path stays
|
||||
for back-compat. Fallback mutation stays — now per-instance.
|
||||
- **Threading through:** `create_app` constructs the factory once;
|
||||
`create_run` does `model, tier = await factory.resolve(user, settings)`
|
||||
and passes `model` into `RunActor` — **engine contracts unchanged**;
|
||||
only the instance origin changes. `RunRegistry` keys become
|
||||
`(owner_id, run_id)`. `state.json` records `owner_id` and
|
||||
`credential_tier` for auditability.
|
||||
- **Probe-per-run:** the existing `supports_probe` + `probe_model_adapter`
|
||||
+ `actor.attach_probe(...)` flow operates on the adapter passed to
|
||||
`create_run` — per-tier automatically. `GET /api/config` resolves the
|
||||
factory for the current user WITHOUT creating a run; cache in-process
|
||||
keyed by `(user_id, tier, key_fingerprint)` with short TTL.
|
||||
|
||||
**Pitfalls:**
|
||||
- `config_status` reads `runs._actors` (private) to steal the last run's
|
||||
probe (`app.py:174-177`) — breaks with zero runs and leaks another
|
||||
user's probe; replace with the factory-resolved probe.
|
||||
- Don't cache adapters across runs — per-run lifetime keeps
|
||||
`_last_fallback_reason` honest.
|
||||
|
||||
## 5. Storage partitioning (D-106, D-107) — exact touchpoints
|
||||
|
||||
**Recommendation:**
|
||||
- **Owner-scoped adapter instances, not owner-in-signatures.**
|
||||
`LocalStorageAdapter(base_dir, owner_id)` binds the owner at
|
||||
construction; `run_dir(run_id) -> base_dir/"runs"/owner_id/run_id`.
|
||||
The `StorageAdapter` protocol stays byte-for-byte unchanged; only
|
||||
constructors gain `owner_id` (validated `^[a-z0-9]{8}$`, D-105).
|
||||
- App wiring: routes construct the owner-scoped adapter from the auth
|
||||
dependency per request. `RunState` gains `owner_id: str | None = None`
|
||||
(serialization-compatible; no migration per D-106).
|
||||
`make_run_config(...)` gains `owner_id` and stamps it on state.
|
||||
- `RunRegistry` keys `_actors[(owner_id, run_id)]`; cross-owner access
|
||||
is a 404.
|
||||
- **D-107 data root:** new `researchx.config.paths.data_root()`:
|
||||
`os.environ["RESEARCHX_DATA_DIR"]` else `backend/var/researchx` (dev);
|
||||
container `/var/lib/researchx`. Replaces the cwd-dependent
|
||||
`Path.cwd()/".researchx"` (`app.py:150`). Layout:
|
||||
```
|
||||
$RESEARCHX_DATA_DIR/
|
||||
users.json sessions.json
|
||||
runs/<user_id>/<run_id>/{state.json, trace.jsonl, report.md,
|
||||
report.json, evidence.json, ledger.json, checklist.md}
|
||||
keys/<user_id>.json
|
||||
settings/<user_id>.json
|
||||
```
|
||||
- **Exact touchpoint list (verified in-tree):**
|
||||
1. `adapters/local_storage.py` — `__init__(owner_id)`, `run_dir`, +
|
||||
`list_run_ids() -> list[str]` (read-models scan).
|
||||
2. `adapters/storage.py` — protocol unchanged; add `append_run_artifact`
|
||||
(actor uses it, protocol lacks it).
|
||||
3. `engine/run_state.py` — `owner_id` field.
|
||||
4. `engine/actor.py` — owner-scoped storage; `make_run_config(owner_id)`;
|
||||
artifacts at DONE (topic 6).
|
||||
5. `api/app.py` — per-request storage, registry keys, config display =
|
||||
data root, `create_app(..., auth_enabled: bool = True)`.
|
||||
6. `api/dev_bridge.py` — owner + auth off.
|
||||
7. `engine/demo.py` — storage constructor gains owner.
|
||||
8. Tests constructing storage/actor: `test_local_storage.py`
|
||||
(layout assertion + traversal), `test_engine.py`,
|
||||
`test_probe_wiring.py`, `test_adversarial.py`, `test_resume.py`,
|
||||
`test_integration_p1.py`, `test_p0_regressions.py`, `test_api.py` —
|
||||
mechanical `owner_id="u1"`-style constructor args.
|
||||
9. Frontend untouched by partitioning (ids stay run-scoped).
|
||||
|
||||
**Pitfalls:**
|
||||
- `owner_id` must be REQUIRED in `LocalStorageAdapter` — an optional
|
||||
default invites silent unpartitioned use.
|
||||
- `read_run_artifact`'s error message embeds `runs/{run_id}/{name}` —
|
||||
update to owner path; tests may pin it.
|
||||
- Consolidate the duplicate run-id generators (`local_storage` vs
|
||||
`run_config.new_run_id` — different alphabets) when touching both.
|
||||
|
||||
## 6. New persisted artifacts: report.json + evidence.json (D-113)
|
||||
|
||||
**Recommendation:**
|
||||
- Persist both at DONE inside `_verify_phase` (actor.py:315-332), right
|
||||
after `report.md`/`ledger.json` — deterministic, no model calls.
|
||||
- **Marker normalization (frontend-compat crux):** the writer emits
|
||||
`[p-N]` (trust loop regex `\[p-(\d+)\]`); the Reading Room parses
|
||||
`[^pN]` with `[A-Za-z0-9]+` only (`report-body.ts:18` — `[^p-1]` does
|
||||
NOT match). Normalize at persistence: in `report.json` section bodies
|
||||
`[p-3]` → `[^p3]`, ids `p-3` → `p3`. `report.md` keeps the raw form.
|
||||
- **report.json**: `{v, run_id, owner_id, title, question, methodology_slug,
|
||||
generated_at, sections: [{heading, body}], citations: [{passage_id, url,
|
||||
title, quote, health, authority_tier}]}`. Sections split from
|
||||
report.md headings; title = first H1 else `"Deep Report — {question}"`.
|
||||
- **evidence.json**: `{v, run_id, passages: [{passage_id, url, title,
|
||||
quote, text}]}` — serialized from `EvidenceStore` (ids renumbered `pN`).
|
||||
- **Endpoint enrichment:** `GET /api/runs/{id}/report` returns
|
||||
`{run_id, markdown, report: {...}, evidence: {...}}` (markdown retained,
|
||||
back-compat); ledger unchanged. Live `exportReport` switches to real
|
||||
fields — kills the fabricated `claims_supported: 1` and empty quotes
|
||||
(`live-client.ts:329-384`).
|
||||
- Live-client `getReport` maps report.json → frontend `Report`
|
||||
(citations → `Citation` with real quotes; tier map official→1,
|
||||
reference→2, else 3).
|
||||
|
||||
**Pitfalls:**
|
||||
- Do NOT change `report.md`'s marker syntax — `_claims_per_passage`,
|
||||
`extract_report_claims`, `_cited_urls` regex on `\[p-(\d+)\]`.
|
||||
- Old DONE runs lack report.json — the endpoint must fall back to
|
||||
markdown-only gracefully (pre-hosted artifacts disposable per D-106).
|
||||
- `EvidenceStore` is per-actor and GC'd after the run — evidence.json
|
||||
MUST be written inside `_verify_phase` (D-115 disk-reads later).
|
||||
|
||||
## 7. Live read-models on disk artifacts (D-114, D-115)
|
||||
|
||||
**Recommendation:**
|
||||
- Read-models read disk, never RunRegistry (D-115): a small
|
||||
`api/read_models.py` scans `runs/<owner_id>/*/` loading `state.json`
|
||||
(+ report.json/ledger.json where needed).
|
||||
- **Restart sweep:** on `create_app` startup, rewrite every non-terminal
|
||||
`state.json` as CANCELLED (legal from every non-terminal state,
|
||||
verified `states.py`); in-flight actors die with the process;
|
||||
rehydration out of scope (D-115).
|
||||
- **Registry pruning:** `_actors`/`_buses` are never pruned today (slow
|
||||
multi-user leak); drop terminal entries once no live SSE subscriber
|
||||
(refcount in the events route); events 404 after prune/restart is
|
||||
documented, trace persists on disk.
|
||||
- **Open-question semantics (D-114):** open questions = plan
|
||||
sub-questions of the user's runs, answered-iff `state == DONE`; no
|
||||
synthetic suggestions; empty states for new users.
|
||||
- Route shapes (owner-scoped via the auth dependency):
|
||||
- `GET /api/library?sort=recent|title` → folders (auto-categorized by
|
||||
methodology + deterministic topic tokens from plan sub-questions) +
|
||||
`ReportSummary[]`.
|
||||
- `GET /api/library/connections` → shared-source edges from ledger URL
|
||||
overlap (≥1 shared URL); related-topic edges on methodology +
|
||||
category overlap.
|
||||
- `GET /api/graph` → same edges in the frontend `ProjectGraph`
|
||||
node/edge shape.
|
||||
- `GET /api/tasks` → `InboxItem[] {id: "<run_id>#<idx>", question,
|
||||
sourceRunId, status}` (done-iff-run-DONE else open).
|
||||
- `GET /api/suggestions` → open items joined with run titles:
|
||||
`SuggestedQuery[] {id, question, sourceRunId, sourceReportTitle}`.
|
||||
- `POST /api/refine/questions {draft}` → planner call (2-3 clarifying
|
||||
questions); `POST /api/refine/compose {draft, answers}` → planner
|
||||
composes refined question + context. Non-GET ⇒ CSRF header applies.
|
||||
- `GET/PUT /api/profile` → display_name/email.
|
||||
- `GET/PUT /api/config/settings` (D-111): per-user settings
|
||||
(`settings/<user_id>.json`): model names, methodology defaults,
|
||||
masked key status, storage path display-only.
|
||||
- `GET /api/config` stays read-only, user-contextualized:
|
||||
`resolved_tier: user|server|keyless`, `key_store: enabled|disabled`,
|
||||
per-user cost counters (owner-keyed), probe (§4).
|
||||
- Directory scans skip dotfiles/tmp leftovers defensively.
|
||||
- Refine endpoints spend planner tokens — same credential chain; a
|
||||
keyless-only user with no local Ollama gets an honest 503, not a hang.
|
||||
|
||||
## 8. Hosting: backend Dockerfile + two-service deploy (REQ-83)
|
||||
|
||||
**Recommendation:**
|
||||
- `backend/Dockerfile` (uv idiom, non-root uid 10001, single worker):
|
||||
`python:3.12-slim` + uv from ghcr image; `uv sync --frozen --no-dev`;
|
||||
`useradd --system --uid 10001 researchx`; `mkdir -p /var/lib/researchx`
|
||||
+ chown; `USER researchx`;
|
||||
`CMD ["uvicorn", "researchx.api.main:app", "--host", "0.0.0.0",
|
||||
"--port", "8000", "--workers", "1", "--proxy-headers"]`.
|
||||
`--workers 1` is a hard requirement (in-memory registry + sessions).
|
||||
- **New production entrypoint `researchx.api.main`:** today `app.py`'s
|
||||
module-level `app = create_app()` serves **FakeModelAdapter +
|
||||
cwd-based storage** — hosting that would silently ship fakes.
|
||||
`main.py` wires `create_app(data_root=RESEARCHX_DATA_DIR, factory=
|
||||
<live factory from env-file secrets>, auth_enabled=True)`. `app.py`
|
||||
stays the test factory.
|
||||
- Secrets at runtime only: `RESEARCHX_KEK`, `OLLAMA_API_KEY`,
|
||||
`OLLAMA_BASE_URL`, `TAVILY_API_KEY` via `--env-file` — never baked
|
||||
into the image, never in git.
|
||||
- **Plain docker-run pairs, not compose** — matches the existing
|
||||
deploy.sh idiom and avoids a compose dependency on the LXC host:
|
||||
`researchx-net` bridge network; `researchx-api` backend (env-file,
|
||||
data volume, `-p 127.0.0.1:8000:8000` if HAProxy is host-side);
|
||||
`researchx` frontend (`-p 3000:3000`, live build args). Keep
|
||||
`researchx-api-prev` + `researchx-prev` for rollback.
|
||||
- **deploy.sh extension:** rsync now includes `backend/`; build both
|
||||
images on the host; health check BOTH `/healthz` (backend) and `/ask`
|
||||
(frontend); update the HAProxy backend section idempotently.
|
||||
- Data volume: pre-create + `chown 10001` host-side (belt-and-braces).
|
||||
- New pyproject deps: `argon2-cffi>=23.1,<26`, `cryptography>=43`; no
|
||||
`python-multipart` (JSON login bodies only).
|
||||
|
||||
**Pitfalls:**
|
||||
- The volume must be writable by uid 10001 — a root-owned bind mount
|
||||
makes every artifact write EACCES.
|
||||
- uvicorn graceful shutdown default lets in-flight runs exercise
|
||||
`_persist_cancelled` on redeploy; `docker rm -f` SIGKILLs — the
|
||||
startup sweep (§7) is the real safety net.
|
||||
|
||||
## 9. HAProxy SSE configuration (REQ-84)
|
||||
|
||||
**Recommendation:**
|
||||
- The host ALREADY runs HAProxy 2.8 with TLS for research.coreci.dev —
|
||||
this is a front-config extension, not a new component:
|
||||
```
|
||||
frontend https-in
|
||||
acl is_api path_beg /api
|
||||
acl is_health path /healthz
|
||||
use_backend backend_api if is_api or is_health
|
||||
default_backend frontend_next
|
||||
|
||||
backend backend_api
|
||||
option httpchk GET /healthz
|
||||
default-server inter 5s fall 3 rise 2
|
||||
server api1 127.0.0.1:8000 check
|
||||
timeout server 3600s
|
||||
timeout tunnel 3600s
|
||||
|
||||
backend frontend_next
|
||||
timeout server 300s
|
||||
server next1 127.0.0.1:3000 check
|
||||
```
|
||||
- HAProxy 2.x does not buffer response bodies by default in HTTP mode —
|
||||
SSE streams natively. The three buffering hazards: NO
|
||||
`option http-buffer-response`, NO compression on the API backend
|
||||
(gzip buffers text/event-stream), keep default http-keep-alive.
|
||||
- Verified: `sse-starlette` sends `Cache-Control: no-store` +
|
||||
`X-Accel-Buffering: no` and emits `: ping` every **15s**
|
||||
(`DEFAULT_PING_INTERVAL = 15`) — defeats idle timeouts.
|
||||
- `timeout client` in global/defaults must exceed the 15s ping interval.
|
||||
- Health check on `/healthz` (unauthenticated, outside /api per D-103):
|
||||
`GET /healthz → 200 {"status":"ok"}`.
|
||||
|
||||
**Pitfalls:**
|
||||
- Enabling global compression "to save bandwidth" is the classic way SSE
|
||||
silently degrades — scope it off for the API backend.
|
||||
- Don't disable the sse-starlette ping.
|
||||
|
||||
## 10. Next.js build-time env + EventSource credentials (D-112)
|
||||
|
||||
**Recommendation:**
|
||||
- `NEXT_PUBLIC_*` vars are inlined at BUILD time — two build profiles:
|
||||
- **dev/test (default):** no env; mode resolves mock; zero keys
|
||||
(REQ-31 preserved, D-112).
|
||||
- **hosted:** `pnpm build` with
|
||||
`NEXT_PUBLIC_RESEARCHX_API_MODE=live NEXT_PUBLIC_API_BASE_URL=""` —
|
||||
empty base = same-origin through HAProxy's `/api` path route.
|
||||
`baseUrl()` treats `""` as same-origin (openapi-fetch accepts a
|
||||
relative base; EventSource accepts relative URLs). Pin dev live-mode
|
||||
docs to `http://localhost:8000` (§1 hostname rule).
|
||||
- `deploy.sh` frontend build gains the build args; the Dockerfile build
|
||||
stage passes them (`ARG`/`ENV` before `pnpm build`).
|
||||
- EventSource: `new EventSource(url, { withCredentials: true })` — the
|
||||
polyfill supports the same init dict. Same-origin hosted mode doesn't
|
||||
strictly need it; set it anyway.
|
||||
- **Delegation ends (D-112):** `GeneratedApiClient extends MockApiClient`
|
||||
today; in P3 every method is overridden against real endpoints and the
|
||||
inheritance is dropped (a standalone class implementing `ApiClient`);
|
||||
mock remains standalone. OpenAPI snapshot regen (G-005): the new
|
||||
auth/settings/library/refine/admin routes change `openapi.json` —
|
||||
commit the snapshot, regen the generated schema, add the ApiClient
|
||||
interface methods (`login/logout/getSession`, admin hooks) as PLANNED
|
||||
extensions (the v0.2 G-002 lesson: document every addition up front).
|
||||
|
||||
**Pitfalls:**
|
||||
- Forgetting the build args yields a hosted instance silently running
|
||||
mock mode — add a deploy.sh smoke assertion: hosted `/api/config`
|
||||
returns 401 + a live-mode marker.
|
||||
- A non-empty `NEXT_PUBLIC_API_BASE_URL` in the hosted build breaks
|
||||
same-origin cookies — assert `""` at build.
|
||||
|
||||
## 11. Dev/E2E harness under auth
|
||||
|
||||
**Recommendation:**
|
||||
- `create_app(..., auth_enabled: bool = True)`. When False, the guard
|
||||
resolves a synthetic dev user (`user_id="dev00000"`, role="admin") —
|
||||
every route behaves exactly as authenticated, owner-scoped, zero keys.
|
||||
- Existing suites keep running unchanged: backend API tests
|
||||
(`test_api.py`, `test_adversarial.py`) and the real-stack E2E
|
||||
(`dev_bridge.py`) construct with `auth_enabled=False`. Engine/trust
|
||||
suites only need the `owner_id` constructor args (topic 5).
|
||||
- **New auth suite (`test_auth.py`) with `auth_enabled=True`:**
|
||||
first-user-admin (+403 after), wrong password 401, logout revocation,
|
||||
401 on protected routes, CSRF header rejection, cross-user 404,
|
||||
admin create/deactivate (kills sessions), expiry, key-store
|
||||
round-trip + AAD tamper rejection + masked-only responses.
|
||||
|
||||
**Pitfalls:**
|
||||
- Don't gate `auth_enabled` on an env var readable in production images
|
||||
(a baked `RESEARCHX_AUTH=off` violates REQ-85); parameter-only, and
|
||||
`main.py` never passes it.
|
||||
- The E2E custom fetchImpl forwards headers verbatim — once the live
|
||||
client adds the CSRF header, E2E keeps working via GETs + dev-disabled
|
||||
guard; note for the plan, don't build now.
|
||||
|
||||
## 12. Pitfall scan — current code that breaks under multi-user
|
||||
|
||||
1. **`config_status` reads `runs._actors`** (`app.py:174-181`) —
|
||||
multi-user probe leak + zero-run breakage. → factory probe (§4). [P1]
|
||||
2. **Shared `model` instance across runs** (`app.py:152`) — the verified
|
||||
`_active_base` fallback mutation (`ollama.py:73-79`) flips every user
|
||||
to localhost after one cloud 401. → per-run factory (§4). [P1]
|
||||
3. **`app = create_app()` module global** (`app.py:359`) — serves fakes
|
||||
+ cwd storage in production. → `researchx.api.main` (§8). [P2/P4]
|
||||
4. **CORS lacks `allow_credentials`** (`app.py:143-148`) — cookie auth
|
||||
cannot work cross-origin in dev. → §1. [P1]
|
||||
5. **`dev_bridge.py` no auth** — correct for E2E; must stay explicitly
|
||||
`auth_enabled=False`, never the production entry. [verify P1]
|
||||
6. **`RunRegistry` unbounded growth** — prune terminal runs. → §7. [P1]
|
||||
7. **`RunState` no `owner_id`** — cross-user authorization impossible
|
||||
today. → topic 5. [P1]
|
||||
8. **Read endpoints require the registry** (`runs.has(run_id)` guards)
|
||||
— every read dies on restart; a hosted redeploy 404s the whole
|
||||
library. → disk read-models (D-115). [P1/P3]
|
||||
9. **`demo.py`** constructs storage directly — mechanical owner fix. [P1]
|
||||
10. **`test_local_storage.test_run_dir_layout`** asserts unpartitioned
|
||||
path — changes with D-106 (intentional). [P1]
|
||||
11. **Dead code** duplicate `return EventSourceResponse` (app.py:331) —
|
||||
remove while touching the file. [P1]
|
||||
12. **`exportReport` fabricates data** (`live-client.ts:350-389`) —
|
||||
fixed by report.json/evidence.json. [P3]
|
||||
13. **Global `cost_counters`** (`app.py:110`) — per-user (owner-keyed). [P1/P2]
|
||||
14. **Duplicate run-id generators** (different alphabets) — consolidate. [P1]
|
||||
|
||||
## Research Conclusions
|
||||
|
||||
- **C3-01** Auth enforcement = router-level `Depends(require_user)` in
|
||||
`create_app` (public router `/api/auth/*` + `/healthz`; protected the
|
||||
rest), NOT middleware: OpenAPI-visible, dependency_overrides testable,
|
||||
no BaseHTTPMiddleware/SSE interaction.
|
||||
- **C3-02** Sessions: opaque `token_urlsafe(32)` cookies
|
||||
(`researchx_session`, httpOnly, SameSite=Lax, Secure hosted-only),
|
||||
server-side dict + atomic sessions.json, 14-day sliding expiry with
|
||||
>60s persist throttle, token rotation at login, immediate revocation.
|
||||
- **C3-03** argon2id via argon2-cffi `PasswordHasher()` defaults; hash,
|
||||
verify, rehash-on-login all in `asyncio.to_thread`; uniform 401.
|
||||
- **C3-04** CORS: `allow_credentials=True` with explicit localhost
|
||||
origins, added so it wraps auth (last-added = outermost in starlette
|
||||
0.46.2); dev origins must share the hostname `localhost` (port is not
|
||||
part of same-site computation).
|
||||
- **C3-05** CSRF = SameSite=Lax + `X-ResearchX-Request` header on all
|
||||
non-GET /api (checked in the auth dependency); EventSource GET exempt;
|
||||
fetch `credentials:"include"` throughout.
|
||||
- **C3-06** Key store: AES-256-GCM via cryptography AESGCM, KEK =
|
||||
32-byte RESEARCHX_KEK (b64/hex) from the env-file 'keys' scope, AAD =
|
||||
user_id, `os.urandom(12)` nonce per write, `keys/<user_id>.json`
|
||||
(v1/entries format, atomic, 0600); masked-only API; honest disabled
|
||||
status; InvalidTag surfaced.
|
||||
- **C3-07** Per-run model factory: `resolve(user) → (adapter, tier)`,
|
||||
chain user→server→keyless; OllamaModelAdapter gains optional `api_key`
|
||||
constructor param; engine contracts unchanged; probe-per-tier falls
|
||||
out of the existing flow; GET /api/config = current user's tier +
|
||||
fingerprint-cached probe.
|
||||
- **C3-08** Partitioning = owner-scoped `LocalStorageAdapter(base_dir,
|
||||
owner_id)` (protocol unchanged), `runs/<owner_id>/<run_id>/`, owner_id
|
||||
in RunState, registry keyed (owner_id, run_id), per-request adapters;
|
||||
~10 enumerated touchpoints incl. 8 test files + demo.py + dev_bridge.
|
||||
- **C3-09** Data root = RESEARCHX_DATA_DIR (dev backend/var/researchx,
|
||||
container /var/lib/researchx) via a new `paths.data_root()`; the
|
||||
module-level create_app() cwd default + fake adapters are replaced by
|
||||
a production `researchx.api.main` entry.
|
||||
- **C3-10** report.json + evidence.json persisted at DONE in
|
||||
_verify_phase; markers normalized `[p-N]`→`[^pN]`, ids `p-N`→`pN` at
|
||||
the persistence boundary so the Reading Room parses with zero
|
||||
frontend changes; report.md keeps `[p-N]` for the trust loop.
|
||||
- **C3-11** Read-models read disk artifacts only; open questions = plan
|
||||
sub-questions answered-iff DONE; startup sweep marks non-terminal
|
||||
runs CANCELLED; rehydration out of scope.
|
||||
- **C3-12** Registry pruning: drop terminal entries once no SSE
|
||||
subscriber; events 404 after prune/restart documented; trace persists.
|
||||
- **C3-13** Backend container: uv-based non-root Dockerfile (uid 10001,
|
||||
chowned volume), `uvicorn --workers 1` mandatory, runtime-only secrets
|
||||
via --env-file, plain docker-run pairs (no compose dependency),
|
||||
researchx-api-prev rollback, health on both /healthz and /ask.
|
||||
- **C3-14** HAProxy: path route /api + /healthz → backend (2.8 on the
|
||||
LXC host, already TLS-fronting); timeout server/tunnel 3600s, NO
|
||||
http-buffer-response, NO compression on the API backend; sse-starlette
|
||||
15s pings + X-Accel-Buffering keep streams alive; httpchk GET /healthz.
|
||||
- **C3-15** Two build profiles: code default mock; hosted image built
|
||||
with NEXT_PUBLIC_RESEARCHX_API_MODE=live +
|
||||
NEXT_PUBLIC_API_BASE_URL="" (same-origin); deploy smoke asserts live
|
||||
mode; EventSource withCredentials; delegation to MockApiClient ends in
|
||||
P3 (standalone GeneratedApiClient).
|
||||
- **C3-16** Auth harness: create_app(auth_enabled=True) default with a
|
||||
parameter-only dev/test override resolving a synthetic dev user;
|
||||
existing zero-key suites run with it off; dedicated auth suite on.
|
||||
- **C3-17** New pyproject deps: argon2-cffi>=23.1,<26, cryptography>=43;
|
||||
no python-multipart; config.json secrets scopes gain
|
||||
"keys": ["RESEARCHX_KEK"].
|
||||
- **C3-18** ApiClient interface additions are PLANNED (G-002 lesson):
|
||||
login/logout/getSession + admin hooks documented in PLAN before P1;
|
||||
openapi.json snapshot regen is a P1 gate (G-005 discipline carries).
|
||||
- **C3-19** Pitfalls P-1..P-14 (§12) each closed by the phase touching
|
||||
its file: P-1..P-11, P-13, P-14 in P1; P-3 in P2/P4; P-12 in P3;
|
||||
P-5 verified by the auth suite.
|
||||
- **C3-20** All decisions D-101..D-115 confirmed workable against the
|
||||
actual code; refinements folded into PLAN as constraints.
|
||||
+84
-16
@@ -75,37 +75,105 @@ prototype release is **v0.1.8** (final phase patch). Milestone branch
|
||||
merged to main and deleted — tags v0.1.0–v0.1.8 preserve the full
|
||||
history. Next milestone: v0.2 MVP (unblocked).
|
||||
|
||||
## Milestone v0.2 — MVP (feature) — BLOCKED until prototype approval
|
||||
## Milestone v0.2 — MVP (feature) — COMPLETE (approved 2026-09-27)
|
||||
|
||||
Phases renumbered from the original v1.0 plan (1-based within the milestone):
|
||||
Phases renumbered from the original v1.0 plan (1-based within the milestone). Tags run on the v0.2.x line; MAJOR stays 0.
|
||||
|
||||
### Phase 0 — Pre-Execution (v0.2)
|
||||
- **Description**: Milestone pre-execution re-run: PLAN.md refresh against approved prototype decisions (layout learnings, API shape implied by the ApiClient interface), research on any gaps surfaced by approval feedback.
|
||||
- **Status**: blocked (gate: v0.1.4)
|
||||
- **Description**: Milestone pre-execution: SPECIFY (spec validated for v0.2), CLARIFY (D-007..D-020: backend layout, uv+Python 3.12, ports/run-IDs, DDG fallback, openapi-typescript client, SSE+pydantic events, difflib anchoring, liveness rules, durable JSON state, owner-gate halt, no-deploy policy, RESEARCHX_API_MODE), RESEARCH (RESEARCH-v02.md — 8 topics, 14 conclusions, 4 D-refinements), PLAN (P1 vertical-slice plan), GRILL (mechanical nine-axis review), MVP/UX check.
|
||||
- **Requirements covered**: none (planning artifacts)
|
||||
- **Success criteria**: .ciagent/ files refreshed for v0.2; PLAN.md carries User-Facing Surface + Happy Path + UX Acceptance Criteria; PERSONAS.md v0.2 roster active; grill verdict recorded with binding decisions
|
||||
- **Depends on**: v0.1 approval (recorded)
|
||||
- **Status**: complete (PR #9 squash-merged to milestone/v0.2-mvp bbf80ce, tag v0.2.0, release created HTTP 201, phase branch deleted; grill Proceed 0.72 with G-001..G-010 promoted; MVP/UX PASS)
|
||||
|
||||
### Phase 1 — Methodology Framework + Adapter Layer
|
||||
- **Description**: Dial schema (12 dials, typed, safe defaults), methodology registry with Rapid Answer + Deep Report, adapter contracts (model/search/storage/memory/secrets) with local implementations, capability probing with conservative caps (8192 context, embedding batch 8, Ollama cloud-first + local fallback), fake model/search adapters.
|
||||
- **Requirements covered**: REQ-01..06, REQ-08..11, REQ-31
|
||||
- **Status**: blocked
|
||||
- **Description**: Dial schema (12 dials, typed, safe defaults), methodology registry with Rapid Answer + Deep Report, adapter contracts (model/search/storage/memory/secrets) with local implementations, capability probing with conservative caps (8192 context, embedding batch 8, Ollama cloud-first + local fallback), fake model/search adapters. Full task waves in PLAN.md.
|
||||
- **Requirements covered**: REQ-01..06, REQ-08..11, REQ-31 (REQ-07, REQ-12 ship in P2 per ROADMAP phase mapping — dial-level + warning contracts are proven here, live probing against real endpoints in P2)
|
||||
- **Success criteria**: uv one-command backend; gates green zero-API-key; registry resolves slugs; dial snapshot round-trips; fakes drive full interface; degradation typed + honest
|
||||
- **Depends on**: 0
|
||||
- **Status**: complete (PR #10 squash-merged to milestone/v0.2-mvp dfb738d, tag v0.2.1, release HTTP 201, phase branch deleted; verify PASS 0 P0 / 2 P1 fixed in-phase / 9 P2 flagged; REQ-07/12 contracts proven with fakes per G-001, live probing in P2)
|
||||
|
||||
### Phase 2 — Research Engine + Trust Loop
|
||||
### Phase 2 — Research Engine + Trust Loop (complete — shipped v0.2.2)
|
||||
- **Description**: Asyncio state machine with durable JSON state and pause/cancel/resume; plan approval gate; typed event bus → SSE; evidence store with stable passage IDs; trust loop (visited-only constraint, URL liveness pass, quote anchoring, citation ledger, verification checklist, confidence labels).
|
||||
- **Requirements covered**: REQ-07, REQ-12, REQ-13..24
|
||||
- **Status**: blocked
|
||||
- **Status**: complete (PR #11 squash-merged 558f21a, tag v0.2.2, release HTTP 201, phase branch deleted; verify found 2 P0 + 9 P1, all fixed in-phase with regression pins; SSE transport itself lands in P3 — the bus + trace artifacts shipped here)
|
||||
|
||||
### Phase 3 — API Layer + Integration
|
||||
### Phase 3 — API Layer + Integration (complete — shipped v0.2.3)
|
||||
- **Description**: FastAPI routes (runs, methodologies, lifecycle, SSE events, config status); OpenAPI-generated typed client; **swap MockApiClient → generated client** (the ApiClient interface absorbs any prototype-era API drift); ETA/cost heuristics for methodology cards.
|
||||
- **Requirements covered**: REQ-25, REQ-26
|
||||
- **Status**: blocked
|
||||
- **Status**: complete (PR #12 squash-merged 75479a9, tag v0.2.3, release HTTP 201, phase branch deleted; verify found 1 P0 mode-flag + 4 P1, all fixed in-phase; REQ-33 one-command story completed here per plan)
|
||||
|
||||
### Phase 4 — Hardening + Adversarial Suite + E2E
|
||||
- **Description**: Adversarial test suite (hallucinated-URL writer, quote-anchor mismatch, pause/resume mid-depth-loop), E2E happy-path (Playwright), monorepo tooling polish (one-command install/dev, lint/typecheck/test green).
|
||||
### Phase 4 — Hardening + Adversarial Suite + E2E (complete — shipped v0.2.4)
|
||||
- **Description**: Adversarial test suite (hallucinated-URL writer, quote-anchor mismatch, pause/resume mid-depth-loop), E2E happy-path (real stack via live binding + dev bridge), monorepo tooling polish (one-command install/dev, lint/typecheck/test green).
|
||||
- **Requirements covered**: REQ-17, REQ-32, REQ-33, REQ-34
|
||||
- **Status**: blocked
|
||||
- **Status**: complete (PR #13 squash-merged dcf2475, tag v0.2.4, release HTTP 201, phase branch deleted; verify found 5 P1 honesty gaps in the suite itself — export gate anchored, weak band pinned, fabricated reports fail export; E2E validated on the real stack, defaults only)
|
||||
|
||||
### Phase 5 — Final Review + Ship (FINAL)
|
||||
- **Description**: Review + audit + milestone ship; final patch tag IS the MVP release. MAJOR stays 0 — the MVP ships as a 0.x release. v1.0.0 comes only after an owner-approved release decision.
|
||||
- **Status**: blocked
|
||||
- **Status**: complete (PR #14 squash-merged 023667e, tag v0.2.5, release HTTP 201, phase branch deleted; review 4 P0 + 15 P1 — P0s attack-proven fixed; audit PASS-WITH-NOTES — criticals fixed; **owner approval RECORDED 2026-09-27** — decision commit 4721c2a, G-009 scope ratified; milestone merged to main, v0.2.5 = the MVP release)
|
||||
|
||||
## Milestone v0.2 — COMPLETE (approved 2026-09-27)
|
||||
|
||||
All requirements complete: REQ-01..26, REQ-31..34 (the MVP set). The
|
||||
approved MVP release is **v0.2.5** (final phase patch). Delivered: the
|
||||
FastAPI research engine (asyncio state machine, durable
|
||||
pause/cancel/resume), methodology framework (12 dials, Rapid Answer +
|
||||
Deep Report), five-adapter layer (Ollama cloud-first fallback,
|
||||
Tavily/DDG, storage, memory, secrets), the attack-proven trust loop
|
||||
(real quote anchoring, cited-scoped ≥99% liveness export gate, SSRF-
|
||||
guarded fetching, citation ledger, verification checklist), SSE live
|
||||
trace, OpenAPI-generated typed client behind the unchanged ApiClient
|
||||
boundary, a 14-attack adversarial suite, and the 3-click E2E on the
|
||||
real stack. G-009 ratified: live mode = core research loop only;
|
||||
library/settings/suggestions/refine/graph stay mock-backed until
|
||||
v0.3+. Milestone branch merged to main and deleted — tags
|
||||
v0.2.0–v0.2.5 preserve the full history. Next: v0.3 planning.
|
||||
|
||||
## Milestone v0.3 — Hosted (feature) — owner-directed 2026-09-29
|
||||
|
||||
Scope (owner session 2026-09-29): one milestone combining the G-009-ratified live surfaces + hosting with three owner additions — auth/sessions (user/pass, admin-managed, OAuth deferred), per-user model API keys, and real inference as the product default. Tags run on the v0.3.x line; MAJOR stays 0; feature milestone — each phase ships a patch, the final phase's patch IS the milestone release.
|
||||
|
||||
### Phase 0 — Pre-Execution (v0.3)
|
||||
- **Description**: SPECIFY (v0.3 requirement set REQ-69..85; single-user constraint superseded), CLARIFY (auth/session mechanics, key-encryption, partitioning, mock's fate, Reading-Room fidelity), RESEARCH (FastAPI session/auth patterns, encrypted key stores, SSE-proxying, storage read-model), PLAN (vertical slices), GRILL, MVP/UX check.
|
||||
- **Requirements covered**: none (planning artifacts)
|
||||
- **Success criteria**: .ciagent/ files refreshed for v0.3; PLAN.md carries User-Facing Surface + Happy Path + UX Acceptance Criteria; PERSONAS.md v0.3 roster active; grill verdict recorded with binding decisions
|
||||
- **Depends on**: v0.2 approval (recorded)
|
||||
- **Status**: complete (PR #16 squash-merged to milestone/v0.3-hosted 1adfa87, tag v0.3.0, release created; D-101..D-115 clarified; RESEARCH-v03 with C3-01..C3-20; PLAN P1..P4 vertical slices; grill Proceed 0.68 with G-101..G-110 bound, G-111 escalated; MVP/UX PASS)
|
||||
|
||||
### Phase 1 — Auth + Sessions + User Scoping
|
||||
- **Description**: User model (argon2id), register/login/logout/me, httpOnly cookie sessions (server-side store), first-user-is-admin + admin-managed accounts (REQ-70), auth guard on every API endpoint, runs/settings partitioned by owner (engine + storage paths). Frontend: login screen, real profile in user menu, logout.
|
||||
- **Requirements covered**: REQ-69, REQ-70, REQ-71
|
||||
- **Success criteria**: unauthenticated API access returns 401; two users cannot see each other's runs; admin can create/deactivate accounts; existing engine tests green under an authenticated harness
|
||||
- **Depends on**: 0
|
||||
- **Status**: complete (PR #17 squash-merged to milestone/v0.3-hosted 2a61433, tag v0.3.1, release HTTP 201, phase branch deleted; verify PASS 4/4 — 184 backend + 119 frontend tests; security P0 register race + P1 corrupt-store fail-closed fixed in-phase; D-116 rate-limit decision recorded)
|
||||
|
||||
### Phase 2 — Per-User Settings + Model Keys
|
||||
- **Description**: Encrypted per-user key store (KEK from env-file secrets scope), per-user settings persistence (PUT/GET /api/config), per-run credential resolution chain (user key → server key → keyless localhost; REQ-74) with probe per resolved key, settings UI live-bound with masked keys.
|
||||
- **Requirements covered**: REQ-72, REQ-73, REQ-74
|
||||
- **Success criteria**: a keyed user gets fully real inference end-to-end; keys never appear in API responses (masked only), logs, or traces; resolution tier surfaced in config status; zero-key tests stay green via REQ-31 fakes
|
||||
- **Depends on**: 1
|
||||
- **Status**: complete (PR #18 squash-merged to milestone/v0.3-hosted ee26ff1, tag v0.3.2, release HTTP 201, phase branch deleted; verify PASS-WITH-FIXES — P0 settings null-poisoning fixed in-phase; 264 backend + 131 frontend tests)
|
||||
|
||||
### Phase 3 — Live Surfaces (user-scoped)
|
||||
- **Description**: Library read-model (GET /api/library), connections + graph (GET /api/library/connections, GET /api/graph), task inbox (GET /api/tasks), suggestions (GET /api/suggestions), refine (POST /api/refine/*, real planner calls), profile (GET/PUT /api/profile), live Reading Room fidelity (structured report, anchored citations, ledger panel). G-002 boundary lifted; frontend live swaps with zero component rewrites (REQ-54).
|
||||
- **Requirements covered**: REQ-76, REQ-77, REQ-78, REQ-79, REQ-80, REQ-81, REQ-82
|
||||
- **Success criteria**: every prototype surface works against real user data in live mode; suggestions/refine driven by real model calls; Reading Room parity with mock; ≤3-click path still green
|
||||
- **Depends on**: 2
|
||||
- **Status**: pending
|
||||
|
||||
### Phase 4 — Hosting + Real-Inference Default
|
||||
- **Description**: Backend Dockerfile (single worker, non-root), two-service deployment via deploy.sh extension, SSE-safe HAProxy path routing (/api → backend, streaming timeouts), hosted instance runs live mode by default (mock demoted to dev/test fixture), auth guard verified through the public URL.
|
||||
- **Requirements covered**: REQ-75, REQ-83, REQ-84, REQ-85
|
||||
- **Success criteria**: one-command deploy brings up both services; SSE streams unbuffered through the proxy; unauthenticated curl to the public /api returns 401; live-mode default proven on the staged instance
|
||||
- **Depends on**: 3
|
||||
- **Status**: pending
|
||||
|
||||
### Phase 5 — Final Review + Ship (FINAL)
|
||||
- **Description**: Multi-persona review of the full milestone diff, audit, tag v0.3.5 = milestone release; pipeline HALTS at the merge_to_main owner approval gate (D-018 carries); deployment to research.coreci.dev only on owner directive (D-019 carries).
|
||||
- **Requirements covered**: milestone gate (REQ-69..85)
|
||||
- **Success criteria**: review PASS (P0s fixed in-phase); audit PASS; v0.3.5 tagged + release; pipeline halted for the recorded owner decision
|
||||
- **Depends on**: 4
|
||||
- **Status**: pending
|
||||
|
||||
## Milestone Releases
|
||||
- **v0.1.4 = extended-prototype candidate** (superseded by the P5/P6 extension cycle)
|
||||
@@ -113,7 +181,7 @@ Phases renumbered from the original v1.0 plan (1-based within the milestone):
|
||||
- **v0.1.6 = extended-prototype candidate** (superseded by the P7/P8 extension cycle)
|
||||
- **v0.1.7 = ask suggestions + refine dialogue** (P7 patch)
|
||||
- **v0.1.8 = milestone release** (P8 final phase patch — the approved extended prototype release, pending owner decision)
|
||||
- **v0.2.x = MVP release** (MAJOR still 0; ship only if all v0.2 phases complete)
|
||||
- **v0.2.5 = milestone release** (P5 final phase patch — the approved MVP; owner approval recorded 2026-09-27, G-009 ratified)
|
||||
- If MVP scope extends: additional 0.x minor milestones (v0.3, …), each a full milestone cycle
|
||||
- **v1.0.0**: owner-approved release only — not created by the pipeline autonomously
|
||||
|
||||
|
||||
+25
-10
@@ -2,18 +2,23 @@
|
||||
"ci_version": "0.7.0",
|
||||
"projects": [],
|
||||
"active_project": "researchx",
|
||||
"milestone": "v0.2",
|
||||
"milestone_name": "mvp",
|
||||
"milestone": "v0.3",
|
||||
"milestone_name": "hosted",
|
||||
"milestone_type": "feature",
|
||||
"versioning": {
|
||||
"major_locked": true,
|
||||
"major_lock_note": "MAJOR remains 0 until an owner-approved v1 release; the MVP may span multiple 0.x minor milestones; all pre-v1 ships are MINOR/PATCH only",
|
||||
"tag_line": "v0.2.x",
|
||||
"next_milestone": "v0.3+ (post-MVP backlog milestones)"
|
||||
"major_lock_note": "MAJOR remains 0 until an owner-approved v1 release; all pre-v1 ships are MINOR/PATCH only",
|
||||
"tag_line": "v0.3.x",
|
||||
"next_milestone": "v0.4 candidates (owner-directed post-v0.3): remaining 6 methodologies (REQ-35..40), OAuth deferred from v0.3 auth, Exa/Perplexity search adapters, S3/git storage, memory scopes"
|
||||
},
|
||||
"autonomy": {
|
||||
"level": "full",
|
||||
"escalation_hooks": ["deploy", "delete_data", "merge_to_main", "prototype_approval"],
|
||||
"escalation_hooks": [
|
||||
"deploy",
|
||||
"delete_data",
|
||||
"merge_to_main",
|
||||
"prototype_approval"
|
||||
],
|
||||
"decision_confidence_threshold": 0.6,
|
||||
"max_revision_iterations": 3,
|
||||
"clarify_budget": 10
|
||||
@@ -37,9 +42,19 @@
|
||||
"secrets": {
|
||||
"env_file": ".ciagent/.env.secrets",
|
||||
"scopes": {
|
||||
"release": ["GITEA_TOKEN"],
|
||||
"model": ["OLLAMA_API_KEY", "OLLAMA_BASE_URL"],
|
||||
"search": ["TAVILY_API_KEY"]
|
||||
"release": [
|
||||
"GITEA_TOKEN"
|
||||
],
|
||||
"model": [
|
||||
"OLLAMA_API_KEY",
|
||||
"OLLAMA_BASE_URL"
|
||||
],
|
||||
"search": [
|
||||
"TAVILY_API_KEY"
|
||||
],
|
||||
"keys": [
|
||||
"RESEARCHX_KEK"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
# Deploy Record — UAT stagings of the v0.1 prototype
|
||||
# Deploy Record — UAT stagings and releases
|
||||
|
||||
Owner-directed UAT deployments, recorded per commit discipline. The
|
||||
approval gate (REQ-62) stays closed; UAT variants stage review builds
|
||||
without minting release tags.
|
||||
Owner-directed deployments, recorded per commit discipline. Release
|
||||
deploys carry the owner approval (v0.1.8); UAT variants stage review
|
||||
builds without minting release tags.
|
||||
|
||||
| Date | Version | Commit | Directive | Result |
|
||||
|---|---|---|---|---|
|
||||
@@ -11,6 +11,8 @@ without minting release tags.
|
||||
| 2026-09-27 | v0.1.7-uat | 432d2c0 (P7 ship + boundary docs) | owner cadence: deploy after P7 and P8 | healthy — http://10.0.0.114:3000 |
|
||||
| 2026-09-27 | v0.1.8-uat | 93a7b57 (P8 ship — release candidate) | owner cadence: deploy after P7 and P8 | healthy — http://10.0.0.114:3000 |
|
||||
| 2026-09-27 | **v0.1.8 (release)** | 93a7b57 (tagged) | **owner approval recorded — milestone release published** | healthy — http://10.0.0.114:3000 |
|
||||
| 2026-09-27 | v0.2.5-uat | c28a97e (v0.2 milestone HEAD + v0.2.5 version strings) | owner: "deploy and validate against https://research.coreci.dev" | healthy — https://research.coreci.dev (HAProxy/SSL) |
|
||||
| 2026-09-27 | **v0.2.5 (release)** | b11b202 (main merge; tag v0.2.5) | **owner approval recorded (/ci-run APPROVED) — milestone release published; G-009 ratified** | serving — same container (v0.2.5-uat build = release content); no redeploy needed |
|
||||
|
||||
## v0.1.6-uat (extended prototype for owner review)
|
||||
|
||||
@@ -24,4 +26,25 @@ without minting release tags.
|
||||
- Rollback: `ssh root@10.0.0.114 'docker stop researchx; docker rename
|
||||
researchx-prev researchx; docker start researchx'`
|
||||
- The approval gate remains closed: milestone merge to main still halts
|
||||
for the recorded owner decision (REQ-62)
|
||||
for the recorded owner decision (REQ-62)
|
||||
|
||||
## v0.2.5-uat (MVP release candidate, owner-directed)
|
||||
|
||||
- Deployed via `bash .ciagent/release/deploy.sh v0.2.5-uat` — the v0.2
|
||||
milestone HEAD (c28a97e: tag v0.2.5 + v0.2.5 About/package.json
|
||||
version strings), per the owner's direct request to deploy and
|
||||
validate against the public URL
|
||||
- Gates re-run at deploy time from the milestone HEAD: typecheck,
|
||||
lint, test (97/97), build — green before sync
|
||||
- Image `researchx:v0.2.5-uat` + `latest` on the host; container
|
||||
swapped, `researchx-prev` (v0.1.8) kept for rollback
|
||||
- Health: 2 checks; validated through the HAProxy SSL front:
|
||||
https://research.coreci.dev — /ask /library /settings /choose all
|
||||
200, root 307→/ask, TLS verified; served settings chunk carries
|
||||
"v0.2.5 · MVP release candidate"; the RESEARCHX_API_MODE switch is
|
||||
present in the served bundle (mock default — correct for a
|
||||
frontend-only container; live mode additionally needs the backend
|
||||
API on the host, which is v0.3+ deployment work)
|
||||
- The merge gate (D-018) remains closed: milestone → main still halts
|
||||
for the recorded owner decision, with G-009 (live-mode scope)
|
||||
presented at the same gate
|
||||
@@ -0,0 +1,87 @@
|
||||
# UAT Script — v0.2.5 (MVP release candidate)
|
||||
|
||||
Deployment: https://research.coreci.dev (HAProxy → LXC container
|
||||
`researchx:v0.2.5-uat`, commit c28a97e, mock-backed frontend).
|
||||
|
||||
Scope note (G-009): this container serves the v0.2.5 frontend with
|
||||
scripted mock data. The v0.2 engine (FastAPI, adapters, trust loop)
|
||||
is validated by the automated suites (129 backend + 97 frontend
|
||||
tests, 14 adversarial attacks, real-stack E2E) and runs via the
|
||||
README live-mode locally; hosting the backend is v0.3+ work. This
|
||||
UAT validates the release-candidate build of the product surface.
|
||||
|
||||
## 1. Shell & navigation (REQ-63, REQ-66)
|
||||
|
||||
1. Open https://research.coreci.dev → lands on **/ask** inside the app shell
|
||||
2. Sidebar: **New research / Library / Settings** nav present on every screen
|
||||
3. Top bar: press **⌘K** → navigation palette (screens + stored reports)
|
||||
4. Click the **avatar** (top right) → user menu: local profile, Settings, About
|
||||
5. Pass: shell persistent everywhere, honest preview-marked future slots
|
||||
|
||||
## 2. Version check (this deploy)
|
||||
|
||||
1. Sidebar → **Settings** → scroll to *About & version*
|
||||
2. Pass: reads **"v0.2.5 · MVP release candidate"** (confirms the deployed build)
|
||||
|
||||
## 3. The ≤3-click happy path (REQ-61 / REQ-17 — core criterion)
|
||||
|
||||
1. **/ask**: type a question → click **Research this** (click 1)
|
||||
2. **/choose**: pick a card (Deep Report is the recommended default) → **Start research** (click 2)
|
||||
3. **Plan & Run**: review the plan → **Approve plan** (click 3)
|
||||
4. Pass: exactly 3 clicks, defaults only, research running with a live trace
|
||||
|
||||
## 4. Plan & Run (REQ-57)
|
||||
|
||||
1. Plan approval UI shows sub-questions, source universe, outline
|
||||
2. Watch the live trace stream (scripted replay)
|
||||
3. Press **pause** → trace halts; **resume** → continues; **cancel** → terminal state
|
||||
4. Pass: all three controls behave; no lost events
|
||||
|
||||
## 5. Reading Room + exports (REQ-58, REQ-59, REQ-34)
|
||||
|
||||
1. Open the finished report from the run screen
|
||||
2. Hover/focus any **[n]** citation marker → the exact quoted passage appears
|
||||
3. Open the **citation ledger** panel: URL, quote, health, authority tier
|
||||
4. **Export Markdown** and **Export JSON** → real files download; citations survive as links
|
||||
5. Pass: quotes match exactly; files open; links intact
|
||||
|
||||
## 6. Ask intelligence (REQ-67, REQ-68)
|
||||
|
||||
1. **New research** → below the form: open-question suggestions appear
|
||||
2. Click one → the question box fills; **never auto-starts**
|
||||
3. Type a draft → **Refine question** → answer the 3 clarifying questions → **Use this** → refined question + context fill the form
|
||||
4. Try **Keep original** on a second draft → untouched; keyboard (Enter) works through both flows
|
||||
5. Pass: both helpers are opt-in, keyboard-accessible, drafts preserved on dismiss
|
||||
|
||||
## 7. Library (REQ-64)
|
||||
|
||||
1. Sidebar → **Library**: folder tree, auto-categorized (topic + methodology), sortable (recency/title)
|
||||
2. Open a stored report → its own Reading Room view
|
||||
3. **Connections** panel: shared-source ⇄ and topical → edges
|
||||
4. v2 previews: project graph + task inbox panels render
|
||||
5. Pass: folders, sorting, connections, previews all render from the fixture data
|
||||
|
||||
## 8. Settings (REQ-65)
|
||||
|
||||
1. API keys: masked (never raw); storage directory field
|
||||
2. Methodology defaults: resolved 12-dial display
|
||||
3. Appearance preview; About & version (step 2)
|
||||
4. Pass: everything honest and functional on mock data
|
||||
|
||||
## Optional: hands-on the live engine (not on this URL)
|
||||
|
||||
```bash
|
||||
# locally, two terminals:
|
||||
cd backend && uv sync && uv run uvicorn researchx.api.app:app --port 8000
|
||||
cd frontend && NEXT_PUBLIC_RESEARCHX_API_MODE=live corepack pnpm dev
|
||||
# then repeat steps 3-5 against the real engine (fake adapters, zero keys)
|
||||
```
|
||||
|
||||
## Decision rubric (the gate this UAT feeds)
|
||||
|
||||
- **Approve**: recorded via /ci-run → milestone merges to main,
|
||||
v0.2.5 publishes as the MVP release, v0.3 planning unblocks
|
||||
(with G-009 scope ratified: library/settings/suggestions/refine/
|
||||
graph stay mock-backed until v0.3+)
|
||||
- **Request changes**: feedback becomes binding decisions for a
|
||||
fix cycle (v0.1 precedent: two feedback cycles before approval)
|
||||
@@ -9,7 +9,7 @@ as established by the CIAgent branch strategy and `config.json`.
|
||||
| Milestone | Tags | Deployed by |
|
||||
|---|---|---|
|
||||
| v0.1 prototype | v0.1.0 … v0.1.8 | `bash .ciagent/release/deploy.sh v0.1.8` |
|
||||
| v0.2 MVP | v0.2.0 … | `bash .ciagent/release/deploy.sh v0.2.x` |
|
||||
| v0.2 MVP | v0.2.0 … v0.2.5 | `bash .ciagent/release/deploy.sh v0.2.5-uat` (staged) — see DEPLOY-RECORD.md |
|
||||
|
||||
## Rules
|
||||
|
||||
|
||||
@@ -2,20 +2,99 @@
|
||||
|
||||
A deep-research studio where you bring your own model, storage, and memory; choose a research methodology before a single query fires; and every claim in the final report is anchored to a visited, verified, quotable source.
|
||||
|
||||
**Status: prototype (milestone v0.1)** — UI/UX with mock data. No backend, no API keys, no network calls.
|
||||
**Status: MVP shipped (v0.2.5, owner-approved 2026-09-27)** — the full research engine (FastAPI + asyncio state machine + adapter layer + attack-proven trust loop) behind the v0.1 frontend. Live mode covers the core research loop (G-002/G-009): the engine runs locally per the quickstart; library/settings/suggestions/refine stay mock-backed until v0.3+.
|
||||
|
||||
## Quickstart
|
||||
|
||||
```bash
|
||||
# frontend (prototype, mock-backed — works today)
|
||||
cd frontend
|
||||
pnpm install
|
||||
pnpm dev
|
||||
corepack pnpm install
|
||||
corepack pnpm dev # http://localhost:3000
|
||||
|
||||
# backend (MVP — uv manages Python 3.12 and the venv)
|
||||
cd backend
|
||||
uv sync # installs deps + editable researchx package
|
||||
uv run uvicorn researchx.api.app:app --reload --port 8000
|
||||
uv run pytest # full suite, zero API keys
|
||||
```
|
||||
|
||||
Open http://localhost:3000 — you land on **Ask**, inside the app shell (left sidebar, top bar with ⌘K search and your avatar). Type a question, click **Research this**, pick a methodology on **Choose** (Deep Report is the recommended default), and press **Start research**.
|
||||
|
||||
Three clicks from cold start to initiated research.
|
||||
|
||||
## Monorepo command map
|
||||
|
||||
| Layer | Install | Dev | Gates |
|
||||
|---|---|---|---|
|
||||
| backend (uv, Python 3.12) | `uv sync` | `uv run uvicorn researchx.api.app:app --reload --port 8000` | `uv run ruff check src tests` · `uv run mypy src` · `uv run pytest` |
|
||||
| frontend (pnpm 12.6) | `corepack pnpm install` | `corepack pnpm dev` | `corepack pnpm typecheck` · `corepack pnpm lint` · `corepack pnpm test` · `corepack pnpm build` |
|
||||
|
||||
## Engine architecture (v0.2 P2)
|
||||
|
||||
The research engine is a hand-rolled asyncio state machine:
|
||||
|
||||
```
|
||||
PLANNING → AWAITING_APPROVAL → SEARCHING → EXTRACTING → REFLECTING ─┐
|
||||
(halt until ↑ depth loop (dials.depth) │
|
||||
approved) └────────────────────────────┘
|
||||
→ SYNTHESIZING → VERIFYING → DONE (+ CANCELLED / FAILED terminals)
|
||||
```
|
||||
|
||||
- Durable state: `runs/<id>/state.json` rewritten atomically after every transition; resume re-enters at the recorded cursor with the persisted plan
|
||||
- Event bus: typed events (`run_started`, `query_fired`, `source_visited`, `passage_extracted`, `state_changed`, `run_done`, `run_error`) — trace per run
|
||||
- Visited-only constraint: the writer's context is the evidence store and nothing else
|
||||
- Trust loop: SSRF-guarded liveness (live/stale/dead), quote anchoring (normalize + fuzzy ≥ 0.85, weak matches flagged), citation ledger JSON, exportable verification checklist, single-source vs corroborated confidence labels
|
||||
|
||||
Run artifacts per run: `state.json`, `report.md`, `ledger.json`, `checklist.md`.
|
||||
|
||||
Try it on fakes (no keys): `uv run python -m researchx.engine.demo`
|
||||
|
||||
## Adversarial guarantees (v0.2 P4)
|
||||
|
||||
Every trust-loop claim is attacked by the adversarial suite (`backend/tests/test_adversarial.py`) — these are tests that try to fool the verifier, and their passing proves the defenses:
|
||||
|
||||
| Attack | Defense (proven) |
|
||||
|---|---|
|
||||
| Writer hallucinates a citation to a passage that was never stored | Unknown passage IDs count as **unanchored claims** in the ledger; the export gate fails the report |
|
||||
| Fabricated quote (matches nothing) | Anchoring verdict: no match, flagged — never silently accepted |
|
||||
| Near-miss quote (0.80–0.85 similarity) | Flagged `weak_match` in the ledger — surfaced, not accepted |
|
||||
| Liveness probe redirected to a private/metadata IP | Per-hop redirect re-guarding classifies it **dead** (SSRF refusal) |
|
||||
| Redirect chain ending at a gone page (410) | Classified **dead** |
|
||||
| Oversized page (5MB) response | 2MB read cap — classifies without unbounded memory |
|
||||
| Pause during the depth loop | Durable state persists; resume completes the run over the HTTP API |
|
||||
| Cancel mid-loop | Terminal CANCELLED state persisted, never swallowed |
|
||||
|
||||
REQ-23 (≥99% citation liveness) is a per-run **export gate** enforced by the verification checklist, plus an opt-in keyed live tier (`cd backend && uv run pytest -m live`) — the zero-key CI suite never asserts it vacuously.
|
||||
|
||||
The E2E happy path (≤3 clicks: ask, choose, approve) is validated against the real FastAPI + engine stack, defaults only — not the mock (`frontend/src/lib/api/e2e-real-stack.test.ts`).
|
||||
|
||||
## Adapter configuration (v0.2)
|
||||
|
||||
All keys resolve from env files only (`.env`, `.env.secrets`, `.env.*` — never the shell environment, never git):
|
||||
|
||||
| Variable | Scope | Used by |
|
||||
|---|---|---|
|
||||
| `OLLAMA_API_KEY` | model (cloud) | Ollama cloud endpoint `https://ollama.com/v1`; absent/keyless falls back to `http://localhost:11434/v1` automatically |
|
||||
| `OLLAMA_BASE_URL` | model | Overrides the default cloud base URL |
|
||||
| `TAVILY_API_KEY` | search | Tavily default search; absent falls back to the keyless DuckDuckGo adapter (degraded mode — surfaced in the run trace) |
|
||||
|
||||
The test suite needs **no** keys: fake model/search adapters (REQ-31) drive every engine test.
|
||||
|
||||
## Live mode (v0.2 P3)
|
||||
|
||||
The frontend runs mock-backed by default (works standalone, zero keys). To run the core research loop against the real backend:
|
||||
|
||||
```bash
|
||||
# terminal 1 — backend (fake adapters by default; real Ollama/Tavily adapters
|
||||
# are used by the engine in v0.2.5+ wiring — the served API currently ships
|
||||
# fake-adapter runs for demos and tests; see Adapter configuration below)
|
||||
cd backend && uv run uvicorn researchx.api.app:app --reload --port 8000
|
||||
|
||||
# terminal 2 — frontend in live mode
|
||||
cd frontend && NEXT_PUBLIC_RESEARCHX_API_MODE=live corepack pnpm dev
|
||||
```
|
||||
|
||||
Live mode covers the core loop (Ask → Choose → approve plan → live trace → report + ledger); the library, settings persistence, suggestions, refine, and graph surfaces stay mock-backed until v0.3. The API surface: `POST /api/runs`, `GET /api/methodologies`, run lifecycle (`approve/pause/cancel/resume`), `GET /api/runs/{id}/events` (SSE), `GET /api/config`, report + ledger artifacts.
|
||||
|
||||
## What's in the prototype
|
||||
|
||||
| Screen | What it does |
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
.venv/
|
||||
__pycache__/
|
||||
.pytest_cache/
|
||||
.ruff_cache/
|
||||
.mypy_cache/
|
||||
var/
|
||||
@@ -0,0 +1 @@
|
||||
3.12
|
||||
@@ -0,0 +1,50 @@
|
||||
[project]
|
||||
name = "researchx"
|
||||
version = "0.2.0"
|
||||
description = "ResearchX — methodology-first deep research studio"
|
||||
requires-python = ">=3.12"
|
||||
dependencies = [
|
||||
"fastapi>=0.115,<0.116",
|
||||
"pydantic>=2.8,<3",
|
||||
"uvicorn>=0.30,<1",
|
||||
"sse-starlette>=2.1,<3",
|
||||
"httpx>=0.27,<1",
|
||||
"argon2-cffi>=23.1,<26",
|
||||
"cryptography>=43",
|
||||
]
|
||||
|
||||
[dependency-groups]
|
||||
dev = [
|
||||
"ruff>=0.6",
|
||||
"mypy>=1.11",
|
||||
"pytest>=8",
|
||||
"pytest-asyncio>=0.23",
|
||||
"httpx>=0.28.1",
|
||||
]
|
||||
|
||||
[build-system]
|
||||
requires = ["hatchling"]
|
||||
build-backend = "hatchling.build"
|
||||
|
||||
[tool.hatch.build.targets.wheel]
|
||||
packages = ["src/researchx"]
|
||||
|
||||
[tool.ruff]
|
||||
line-length = 100
|
||||
target-version = "py312"
|
||||
|
||||
[tool.ruff.lint]
|
||||
extend-select = ["I", "UP"]
|
||||
|
||||
[tool.mypy]
|
||||
strict = true
|
||||
mypy_path = "src"
|
||||
python_version = "3.12"
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
pythonpath = ["src"]
|
||||
markers = [
|
||||
"live: opt-in keyed tests against real adapters (skipped without keys)",
|
||||
]
|
||||
asyncio_mode = "auto"
|
||||
testpaths = ["tests"]
|
||||
@@ -0,0 +1,52 @@
|
||||
import json
|
||||
|
||||
import httpx
|
||||
|
||||
from researchx.adapters.model import ModelAdapter
|
||||
from researchx.adapters.probe import (
|
||||
DEFAULT_EMBEDDING_BATCH,
|
||||
CapabilityProbe,
|
||||
cap_context_window,
|
||||
structured_output_warning,
|
||||
)
|
||||
from researchx.adapters.types import ModelMessage
|
||||
|
||||
|
||||
def supports_probe(adapter: ModelAdapter) -> bool:
|
||||
return getattr(adapter, "probing_supported", False)
|
||||
|
||||
|
||||
async def probe_model_adapter(adapter: ModelAdapter) -> CapabilityProbe:
|
||||
models = await adapter.list_models()
|
||||
smoke_ok = await _structured_output_smoke_test(adapter)
|
||||
writer = adapter.roles().writer
|
||||
warning = structured_output_warning(smoke_ok, writer)
|
||||
endpoint = getattr(adapter, "active_base_url", "unknown")
|
||||
fallback_reason = getattr(adapter, "last_fallback_reason", None)
|
||||
return CapabilityProbe(
|
||||
endpoint=str(endpoint),
|
||||
models=models,
|
||||
context_window=cap_context_window(None),
|
||||
embedding_batch=DEFAULT_EMBEDDING_BATCH,
|
||||
structured_outputs_supported=smoke_ok,
|
||||
structured_output_warning=warning,
|
||||
fallback_reason=fallback_reason,
|
||||
)
|
||||
|
||||
|
||||
async def _structured_output_smoke_test(adapter: ModelAdapter) -> bool:
|
||||
probe_messages = [
|
||||
ModelMessage(
|
||||
role="user",
|
||||
content='Return a JSON object with exactly one key "ok" set to true. No other text.',
|
||||
)
|
||||
]
|
||||
try:
|
||||
result = await adapter.chat(probe_messages, model=adapter.roles().writer, json_mode=True)
|
||||
except (httpx.HTTPError, httpx.HTTPStatusError, ValueError, KeyError):
|
||||
return False
|
||||
try:
|
||||
parsed = json.loads(result.content.strip())
|
||||
except json.JSONDecodeError:
|
||||
return False
|
||||
return isinstance(parsed, dict) and parsed.get("ok") is True
|
||||
@@ -0,0 +1,136 @@
|
||||
import re
|
||||
from datetime import UTC, datetime
|
||||
from html import unescape
|
||||
from urllib.parse import parse_qs, unquote, urlparse
|
||||
|
||||
import httpx
|
||||
|
||||
from researchx.adapters.secrets import SecretsProvider
|
||||
from researchx.adapters.tavily import TavilySearchAdapter, extract_text, extract_title
|
||||
from researchx.adapters.types import AdapterError, PageContent, SearchResult
|
||||
from researchx.adapters.url_guard import url_allowed
|
||||
|
||||
_DDG_URL = "https://html.duckduckgo.com/html"
|
||||
_SEARCH_TIMEOUT = 10.0
|
||||
_FETCH_TIMEOUT = 10.0
|
||||
_MAX_FETCH_REDIRECTS = 5
|
||||
_BACKOFF_SECONDS = 1.0
|
||||
_USER_AGENT = "researchx/0.2 (local research studio)"
|
||||
_RESULT_RE = re.compile(
|
||||
r'<a[^>]+class="[^"]*result__a[^"]*"[^>]+href="([^"]+)"[^>]*>(.*?)</a>',
|
||||
re.DOTALL,
|
||||
)
|
||||
_SNIPPET_RE = re.compile(r'<a[^>]+class="[^"]*result__snippet[^"]*"[^>]*>(.*?)</a>', re.DOTALL)
|
||||
_TAG_RE = re.compile(r"<[^>]+>")
|
||||
|
||||
|
||||
class DuckDuckGoSearchAdapter:
|
||||
def __init__(self) -> None:
|
||||
self._last_degradation: str | None = None
|
||||
|
||||
async def search(self, query: str, *, max_results: int = 5) -> list[SearchResult]:
|
||||
results = await self._attempt_search(query, max_results)
|
||||
if results is None:
|
||||
self._last_degradation = "duckduckgo rate-limited or unreachable; returning partial results"
|
||||
return []
|
||||
self._last_degradation = None
|
||||
return results
|
||||
|
||||
async def _attempt_search(self, query: str, max_results: int) -> list[SearchResult] | None:
|
||||
import asyncio
|
||||
|
||||
params = {"q": query}
|
||||
headers = {"User-Agent": _USER_AGENT}
|
||||
for attempt in range(2):
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=_SEARCH_TIMEOUT) as client:
|
||||
response = await client.post(_DDG_URL, params=params, headers=headers)
|
||||
if response.status_code in (403, 429):
|
||||
if attempt == 0:
|
||||
await asyncio.sleep(_BACKOFF_SECONDS)
|
||||
continue
|
||||
return None
|
||||
response.raise_for_status()
|
||||
return self._parse_results(response.text, max_results)
|
||||
except httpx.HTTPError:
|
||||
if attempt == 0:
|
||||
await asyncio.sleep(_BACKOFF_SECONDS)
|
||||
continue
|
||||
return None
|
||||
return None
|
||||
|
||||
def _parse_results(self, html: str, max_results: int) -> list[SearchResult]:
|
||||
anchors = _RESULT_RE.findall(html)
|
||||
snippets = [self._strip(s) for s in _SNIPPET_RE.findall(html)]
|
||||
results: list[SearchResult] = []
|
||||
for index, (href, title_html) in enumerate(anchors):
|
||||
url = self._normalize_href(href)
|
||||
if not url:
|
||||
continue
|
||||
snippet = snippets[index] if index < len(snippets) else ""
|
||||
results.append(
|
||||
SearchResult(
|
||||
title=self._strip(title_html),
|
||||
url=url,
|
||||
snippet=snippet,
|
||||
)
|
||||
)
|
||||
if len(results) >= max_results:
|
||||
break
|
||||
return results
|
||||
|
||||
def _normalize_href(self, href: str) -> str:
|
||||
decoded = unescape(href)
|
||||
parsed = urlparse(decoded)
|
||||
if parsed.path.startswith("//duckduckgo.com/l/") or parsed.netloc == "duckduckgo.com":
|
||||
target = parse_qs(parsed.query).get("uddg", [""])[0]
|
||||
return unquote(target)
|
||||
if decoded.startswith("http"):
|
||||
return decoded
|
||||
return ""
|
||||
|
||||
def _strip(self, html: str) -> str:
|
||||
text = _TAG_RE.sub("", html)
|
||||
return re.sub(r"\s+", " ", text).strip()
|
||||
|
||||
async def fetch(self, url: str) -> PageContent:
|
||||
from urllib.parse import urljoin
|
||||
|
||||
current = url
|
||||
async with httpx.AsyncClient(
|
||||
timeout=_FETCH_TIMEOUT,
|
||||
follow_redirects=False,
|
||||
headers={"User-Agent": _USER_AGENT},
|
||||
) as client:
|
||||
for _ in range(_MAX_FETCH_REDIRECTS + 1):
|
||||
if not url_allowed(current):
|
||||
raise AdapterError(
|
||||
"fetch refused: url not allowed (private or non-http scheme)"
|
||||
)
|
||||
response = await client.get(current)
|
||||
if 300 <= response.status_code < 400:
|
||||
location = response.headers.get("location")
|
||||
if not location:
|
||||
raise AdapterError("fetch failed: redirect without location")
|
||||
current = urljoin(current, location)
|
||||
continue
|
||||
response.raise_for_status()
|
||||
return PageContent(
|
||||
url=url,
|
||||
title=extract_title(response.text),
|
||||
text=extract_text(response.text),
|
||||
fetched_at=datetime.now(UTC),
|
||||
)
|
||||
raise AdapterError("fetch failed: too many redirects")
|
||||
|
||||
@property
|
||||
def last_degradation(self) -> str | None:
|
||||
return self._last_degradation
|
||||
|
||||
|
||||
def resolve_search_adapter(
|
||||
secrets: SecretsProvider,
|
||||
) -> tuple[TavilySearchAdapter | DuckDuckGoSearchAdapter, str]:
|
||||
if secrets.resolve("TAVILY_API_KEY"):
|
||||
return TavilySearchAdapter(secrets), "tavily"
|
||||
return DuckDuckGoSearchAdapter(), "duckduckgo-keyless"
|
||||
@@ -0,0 +1,165 @@
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from researchx.adapters.model import ModelRoles
|
||||
from researchx.adapters.types import (
|
||||
AdapterError,
|
||||
ChatResult,
|
||||
ModelMessage,
|
||||
PageContent,
|
||||
SearchResult,
|
||||
)
|
||||
|
||||
|
||||
def _now() -> datetime:
|
||||
return datetime.now(UTC)
|
||||
|
||||
|
||||
class FakeModelAdapter:
|
||||
def __init__(self, canned: list[ChatResult]) -> None:
|
||||
self._canned = list(canned)
|
||||
self._json_mode_calls: list[bool] = []
|
||||
self.roles_value = ModelRoles(planner="fake-planner", writer="fake-writer")
|
||||
|
||||
async def chat(
|
||||
self,
|
||||
messages: list[ModelMessage],
|
||||
*,
|
||||
model: str,
|
||||
temperature: float = 0.2,
|
||||
json_mode: bool = False,
|
||||
) -> ChatResult:
|
||||
del messages, temperature
|
||||
self._json_mode_calls.append(json_mode)
|
||||
if not self._canned:
|
||||
raise AdapterError(f"no canned response for model {model}")
|
||||
return self._canned.pop(0)
|
||||
|
||||
async def list_models(self) -> list[str]:
|
||||
return ["fake-model"]
|
||||
|
||||
def roles(self) -> ModelRoles:
|
||||
return self.roles_value
|
||||
|
||||
@property
|
||||
def last_json_mode(self) -> bool | None:
|
||||
return self._json_mode_calls[-1] if self._json_mode_calls else None
|
||||
|
||||
|
||||
class FakeSearchAdapter:
|
||||
def __init__(self, corpus: list[PageContent]) -> None:
|
||||
self._corpus = list(corpus)
|
||||
self._by_url = {page.url: page for page in corpus}
|
||||
|
||||
async def search(self, query: str, *, max_results: int = 5) -> list[SearchResult]:
|
||||
terms = [t.lower() for t in query.split() if t]
|
||||
results: list[SearchResult] = []
|
||||
for page in self._corpus:
|
||||
haystack = f"{page.title}\n{page.text}".lower()
|
||||
if all(t in haystack for t in terms):
|
||||
snippet = page.text[:200]
|
||||
results.append(SearchResult(title=page.title, url=page.url, snippet=snippet))
|
||||
if len(results) >= max_results:
|
||||
break
|
||||
return results
|
||||
|
||||
async def fetch(self, url: str) -> PageContent:
|
||||
page = self._by_url.get(url)
|
||||
if page is None:
|
||||
raise AdapterError(f"unknown url: {url}")
|
||||
return page.model_copy(update={"fetched_at": _now()})
|
||||
|
||||
|
||||
SHARED_SENTENCE = (
|
||||
"The verification pass re-anchored every quoted claim to its stored passage "
|
||||
"before the report was allowed to ship."
|
||||
)
|
||||
|
||||
|
||||
def corpus() -> list[PageContent]:
|
||||
stamp = _now()
|
||||
pages = [
|
||||
PageContent(
|
||||
url="https://example.org/verification",
|
||||
title="Verification passes in research pipelines",
|
||||
text=(
|
||||
"Deep research pipelines fail when citations are decorative. "
|
||||
f"{SHARED_SENTENCE} The pipeline treats every quote as a claim "
|
||||
"about evidence, not a claim about the world.\n\n"
|
||||
"Verification therefore runs twice: once at generation time and "
|
||||
"once at export time — liveness is rechecked, anchors are "
|
||||
"re-matched, and the ledger records the outcome."
|
||||
),
|
||||
fetched_at=stamp,
|
||||
),
|
||||
PageContent(
|
||||
url="https://example.org/anchoring",
|
||||
title="Quote anchoring and auditability",
|
||||
text=(
|
||||
f"{SHARED_SENTENCE} Anchoring is the mechanism that makes this "
|
||||
"auditability real: each claim carries a fuzzy-matched pointer "
|
||||
"back to the exact stored passage — d’abord normalisé, puis "
|
||||
"comparé. 近似匹配的阈值设定为 0.85.\n\n"
|
||||
"A claim without an anchor is not a finding; it is a hypothesis "
|
||||
"with better typography."
|
||||
),
|
||||
fetched_at=stamp,
|
||||
),
|
||||
PageContent(
|
||||
url="https://example.org/ollama",
|
||||
title="OpenAI-compatible endpoints on Ollama",
|
||||
text=(
|
||||
"Ollama exposes an OpenAI-compatible surface at /v1: chat "
|
||||
"completions accept the standard message array, and model "
|
||||
"listing works via GET /v1/models. Cloud deployments require "
|
||||
"a bearer key; localhost deployments are keyless by default.\n\n"
|
||||
"Capability probing stays conservative: cap context at the "
|
||||
"minimum of the model claim and 8192 tokens, and batch "
|
||||
"embeddings at 8."
|
||||
),
|
||||
fetched_at=stamp,
|
||||
),
|
||||
PageContent(
|
||||
url="https://example.org/search-routing",
|
||||
title="Search routing across keyless and keyed providers",
|
||||
text=(
|
||||
"A keyed provider (Tavily) is the default route; a keyless "
|
||||
"fallback (DuckDuckGo HTML) degrades gracefully under rate "
|
||||
"limiting. The fallback is a degraded mode, not a second "
|
||||
"source — its warnings surface in the run trace.\n\n"
|
||||
"Routing decisions belong to the methodology dials, never to "
|
||||
"the writer."
|
||||
),
|
||||
fetched_at=stamp,
|
||||
),
|
||||
PageContent(
|
||||
url="https://example.org/state-machines",
|
||||
title="Durable state machines for research engines",
|
||||
text=(
|
||||
"A research engine survives restarts by persisting state after "
|
||||
"every transition: an atomic tmp-plus-replace write leaves no "
|
||||
"torn files, and a cursor records the depth-loop position.\n\n"
|
||||
"Pause clears an asyncio event; cancel persists a terminal "
|
||||
"state; resume re-enters the loop at the recorded cursor. "
|
||||
"CancelledError is re-raised after the write — never swallowed."
|
||||
),
|
||||
fetched_at=stamp,
|
||||
),
|
||||
PageContent(
|
||||
url="https://example.org/methodology",
|
||||
title="Methodology-first research selection",
|
||||
text=(
|
||||
"Choosing a methodology before a single query fires converts "
|
||||
"ad-hoc prompt crafting into a structured, reproducible "
|
||||
"decision. Dials — breadth, depth, citation rigor, "
|
||||
"verification passes — resolve into a snapshot that persists "
|
||||
"with every run.\n\n"
|
||||
"The methodology registry ships Rapid Answer for memo-shaped "
|
||||
"answers and Deep Report for executive reports with full "
|
||||
"verification."
|
||||
),
|
||||
fetched_at=stamp,
|
||||
),
|
||||
]
|
||||
return pages
|
||||
|
||||
CORPUS = corpus()
|
||||
@@ -0,0 +1,112 @@
|
||||
import asyncio
|
||||
import os
|
||||
import re
|
||||
import secrets as secrets_module
|
||||
from pathlib import Path
|
||||
|
||||
from researchx.adapters.types import AdapterError
|
||||
from researchx.config.ids import SLUG_RE, generate_slug
|
||||
|
||||
_ARTIFACT_NAME_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]*$")
|
||||
|
||||
|
||||
def generate_run_id() -> str:
|
||||
"""Generate an 8-char run id (consolidated generator — config/ids.py, P-14)."""
|
||||
return generate_slug()
|
||||
|
||||
|
||||
def _validate_slug(value: str, what: str) -> None:
|
||||
if SLUG_RE.match(value) is None:
|
||||
raise AdapterError(f"invalid {what}")
|
||||
|
||||
|
||||
def _validate_run_id(run_id: str) -> None:
|
||||
_validate_slug(run_id, "run id")
|
||||
|
||||
|
||||
def _validate_owner_id(owner_id: str) -> None:
|
||||
_validate_slug(owner_id, "owner id")
|
||||
|
||||
|
||||
def _validate_artifact_name(name: str) -> None:
|
||||
if not _ARTIFACT_NAME_RE.match(name) or Path(name).name != name:
|
||||
raise AdapterError("invalid artifact name")
|
||||
|
||||
|
||||
class LocalStorageAdapter:
|
||||
"""Owner-scoped local run storage (D-106): runs/<owner_id>/<run_id>/.
|
||||
|
||||
``owner_id`` is REQUIRED (an optional default invites silent
|
||||
unpartitioned use — RESEARCH §5 pitfall) and validated against the
|
||||
shared 8-char slug shape (D-105) so it is a filesystem-safe
|
||||
partition key.
|
||||
"""
|
||||
|
||||
def __init__(self, base_dir: Path, owner_id: str) -> None:
|
||||
_validate_owner_id(owner_id)
|
||||
self._base_dir = base_dir
|
||||
self._owner_id = owner_id
|
||||
|
||||
@property
|
||||
def owner_id(self) -> str:
|
||||
return self._owner_id
|
||||
|
||||
@property
|
||||
def base_dir(self) -> Path:
|
||||
return self._base_dir
|
||||
|
||||
def run_dir(self, run_id: str) -> Path:
|
||||
_validate_run_id(run_id)
|
||||
return self._base_dir / "runs" / self._owner_id / run_id
|
||||
|
||||
def list_run_ids(self) -> list[str]:
|
||||
"""Scan this owner's partition, skipping non-directories/dotfiles."""
|
||||
runs_dir = self._base_dir / "runs" / self._owner_id
|
||||
if not runs_dir.is_dir():
|
||||
return []
|
||||
return sorted(
|
||||
child.name
|
||||
for child in runs_dir.iterdir()
|
||||
if child.is_dir() and not child.name.startswith(".")
|
||||
)
|
||||
|
||||
async def write_run_artifact(self, run_id: str, name: str, content: str) -> Path:
|
||||
_validate_artifact_name(name)
|
||||
target = self.run_dir(run_id) / name
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
tmp_path = target.with_name(f"{target.name}.{secrets_module.token_hex(4)}.tmp")
|
||||
|
||||
def _write_atomic() -> None:
|
||||
with open(tmp_path, "w", encoding="utf-8") as handle:
|
||||
handle.write(content)
|
||||
handle.flush()
|
||||
os.fsync(handle.fileno())
|
||||
tmp_path.replace(target)
|
||||
|
||||
try:
|
||||
await asyncio.to_thread(_write_atomic)
|
||||
finally:
|
||||
tmp_path.unlink(missing_ok=True)
|
||||
return target
|
||||
|
||||
async def read_run_artifact(self, run_id: str, name: str) -> str:
|
||||
_validate_artifact_name(name)
|
||||
target = self.run_dir(run_id) / name
|
||||
try:
|
||||
return target.read_text(encoding="utf-8")
|
||||
except FileNotFoundError as exc:
|
||||
raise AdapterError(
|
||||
f"artifact not found: runs/{self._owner_id}/{run_id}/{name}"
|
||||
) from exc
|
||||
|
||||
async def append_run_artifact(self, run_id: str, name: str, content: str) -> Path:
|
||||
_validate_artifact_name(name)
|
||||
target = self.run_dir(run_id) / name
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
def _append() -> None:
|
||||
with open(target, "a", encoding="utf-8") as handle:
|
||||
handle.write(content)
|
||||
|
||||
await asyncio.to_thread(_append)
|
||||
return target
|
||||
@@ -0,0 +1,8 @@
|
||||
from typing import Protocol, runtime_checkable
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class MemoryAdapter(Protocol):
|
||||
async def remember(self, scope: str, key: str, value: str) -> None: ...
|
||||
|
||||
async def recall(self, scope: str, key: str) -> str | None: ...
|
||||
@@ -0,0 +1,26 @@
|
||||
from typing import Protocol, runtime_checkable
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
from researchx.adapters.types import ChatResult, ModelMessage
|
||||
|
||||
|
||||
class ModelRoles(BaseModel):
|
||||
planner: str
|
||||
writer: str
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class ModelAdapter(Protocol):
|
||||
async def chat(
|
||||
self,
|
||||
messages: list[ModelMessage],
|
||||
*,
|
||||
model: str,
|
||||
temperature: float = 0.2,
|
||||
json_mode: bool = False,
|
||||
) -> ChatResult: ...
|
||||
|
||||
async def list_models(self) -> list[str]: ...
|
||||
|
||||
def roles(self) -> ModelRoles: ...
|
||||
@@ -0,0 +1,163 @@
|
||||
"""Per-run model adapter factory (T-2.5, REQ-74 — D-110, P-2 closure).
|
||||
|
||||
Resolves the per-run credential chain for the CURRENT user:
|
||||
|
||||
user key (decrypted per-user key store) -> tier "user"
|
||||
server env-file OLLAMA_API_KEY -> tier "server"
|
||||
neither (localhost:11434 fallback) -> tier "keyless"
|
||||
|
||||
One adapter instance per ``resolve()`` call — NEVER cached across runs.
|
||||
The per-instance ``_active_base`` fallback mutation in
|
||||
OllamaModelAdapter is then per-run by construction, killing the
|
||||
shared-adapter bug where one user's cloud failure flipped every
|
||||
subsequent user's run to localhost.
|
||||
|
||||
The keyless tier constructs an adapter with no key (no Authorization
|
||||
header is sent — see ollama.py) and the existing cloud→local fallback
|
||||
semantics are preserved: a keyless adapter starts on the cloud base
|
||||
and falls back on failure exactly as before, so localhost:11434
|
||||
remains the zero-cost path.
|
||||
|
||||
Tampered/corrupt key-store envelopes surface as ``KeyStoreError`` (a
|
||||
typed ``AdapterError``) from ``resolve()`` — the caller decides how
|
||||
to present the honest error; the factory NEVER silently falls down the
|
||||
chain on a read failure (that would be a silent tier drop, C3-06).
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
|
||||
from researchx.adapters.model import ModelRoles
|
||||
from researchx.adapters.ollama import OllamaModelAdapter
|
||||
from researchx.adapters.secrets import SecretsProvider
|
||||
from researchx.auth.key_store import KEY_SCOPE, KeyStore, KeyStoreError
|
||||
from researchx.auth.models import UserPublic
|
||||
from researchx.auth.settings import DEFAULT_PLANNER_MODEL, DEFAULT_WRITER_MODEL, UserSettings
|
||||
|
||||
TIER_USER = "user"
|
||||
TIER_SERVER = "server"
|
||||
TIER_KEYLESS = "keyless"
|
||||
|
||||
|
||||
class ModelAdapterFactory:
|
||||
"""Resolve (adapter, tier) for a user's run — per call, never cached."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
secrets: SecretsProvider,
|
||||
key_store: KeyStore,
|
||||
*,
|
||||
planner_model: str = DEFAULT_PLANNER_MODEL,
|
||||
writer_model: str = DEFAULT_WRITER_MODEL,
|
||||
cloud_base_url: str | None = None,
|
||||
local_base_url: str | None = None,
|
||||
) -> None:
|
||||
self._secrets = secrets
|
||||
self._key_store = key_store
|
||||
self._planner_model = planner_model
|
||||
self._writer_model = writer_model
|
||||
self._cloud_base_url = cloud_base_url
|
||||
self._local_base_url = local_base_url
|
||||
|
||||
def _construct(
|
||||
self, planner: str, writer: str, api_key: str | None
|
||||
) -> OllamaModelAdapter:
|
||||
return OllamaModelAdapter(
|
||||
self._secrets,
|
||||
planner_model=planner,
|
||||
writer_model=writer,
|
||||
cloud_base_url=self._cloud_base_url,
|
||||
local_base_url=self._local_base_url,
|
||||
api_key=api_key,
|
||||
)
|
||||
|
||||
async def resolve(
|
||||
self, user: UserPublic, settings: UserSettings
|
||||
) -> tuple[OllamaModelAdapter, str]:
|
||||
"""Resolve the user's credential chain and build ONE adapter.
|
||||
|
||||
The adapter's roles honor the user's saved settings
|
||||
(planner_model/writer_model); an unset settings value keeps the
|
||||
factory defaults. Raises ``KeyStoreError`` (an AdapterError)
|
||||
when the user's stored envelope is tampered/corrupt — never a
|
||||
silent tier drop.
|
||||
"""
|
||||
planner = settings.planner_model or self._planner_model
|
||||
writer = settings.writer_model or self._writer_model
|
||||
|
||||
if self._key_store.enabled:
|
||||
# get_key raises KeyStoreError on tamper — honest error, not
|
||||
# a silent fall to server/keyless.
|
||||
user_key = await self._key_store.get_key(user.user_id)
|
||||
if user_key:
|
||||
return self._construct(planner, writer, user_key), TIER_USER
|
||||
|
||||
server_key = self._secrets.resolve(KEY_SCOPE)
|
||||
if server_key:
|
||||
return self._construct(planner, writer, server_key), TIER_SERVER
|
||||
|
||||
return self._construct(planner, writer, None), TIER_KEYLESS
|
||||
|
||||
async def resolve_status(
|
||||
self, user: UserPublic, settings: UserSettings
|
||||
) -> tuple[OllamaModelAdapter, str, str | None]:
|
||||
"""Status variant for GET /api/config: like resolve() but a
|
||||
tampered/corrupt key-store read DEGRADES down the chain and
|
||||
returns the honest error message instead of raising — the
|
||||
config status must never 500 on a corrupt envelope, and the
|
||||
tier must never silently claim 'user' when the key cannot be
|
||||
decrypted. create_run keeps the raising resolve() (a corrupt
|
||||
key there is an honest 409 failure).
|
||||
"""
|
||||
error: str | None = None
|
||||
planner = settings.planner_model or self._planner_model
|
||||
writer = settings.writer_model or self._writer_model
|
||||
|
||||
if self._key_store.enabled:
|
||||
try:
|
||||
user_key = await self._key_store.get_key(user.user_id)
|
||||
except KeyStoreError as exc:
|
||||
# Honest degradation: the tier falls to server/keyless
|
||||
# but the error message is carried alongside — surfaced
|
||||
# by the config status so the user can see WHY.
|
||||
user_key = None
|
||||
error = str(exc)
|
||||
if user_key:
|
||||
return self._construct(planner, writer, user_key), TIER_USER, None
|
||||
|
||||
server_key = self._secrets.resolve(KEY_SCOPE)
|
||||
if server_key:
|
||||
return self._construct(planner, writer, server_key), TIER_SERVER, error
|
||||
|
||||
return self._construct(planner, writer, None), TIER_KEYLESS, error
|
||||
|
||||
async def key_fingerprint(self, user: UserPublic) -> str:
|
||||
"""Stable per-(user, tier, key) probe-cache key component.
|
||||
|
||||
The raw key is NEVER the fingerprint — a short sha256 of the
|
||||
effective key (or the tier itself when keyless) identifies the
|
||||
resolution without exposing material. A key-store read error
|
||||
yields the server/keyless fingerprint; the probe itself will
|
||||
surface the error state through the config status.
|
||||
"""
|
||||
if self._key_store.enabled:
|
||||
try:
|
||||
user_key = await self._key_store.get_key(user.user_id)
|
||||
except KeyStoreError:
|
||||
user_key = None
|
||||
if user_key:
|
||||
return f"{TIER_USER}:{hashlib.sha256(user_key.encode()).hexdigest()[:12]}"
|
||||
server_key = self._secrets.resolve(KEY_SCOPE)
|
||||
if server_key:
|
||||
return f"{TIER_SERVER}:{hashlib.sha256(server_key.encode()).hexdigest()[:12]}"
|
||||
return TIER_KEYLESS
|
||||
|
||||
def roles_defaults(self) -> ModelRoles:
|
||||
return ModelRoles(planner=self._planner_model, writer=self._writer_model)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"TIER_KEYLESS",
|
||||
"TIER_SERVER",
|
||||
"TIER_USER",
|
||||
"ModelAdapterFactory",
|
||||
]
|
||||
@@ -0,0 +1,129 @@
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
|
||||
from researchx.adapters.model import ModelRoles
|
||||
from researchx.adapters.secrets import SecretsProvider
|
||||
from researchx.adapters.types import AdapterError, ChatResult, ModelMessage, Usage
|
||||
|
||||
_DEFAULT_CLOUD_BASE = "https://ollama.com/v1"
|
||||
_DEFAULT_LOCAL_BASE = "http://localhost:11434/v1"
|
||||
_REQUEST_TIMEOUT = 60.0
|
||||
|
||||
|
||||
class OllamaModelAdapter:
|
||||
probing_supported = True
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
secrets: SecretsProvider,
|
||||
planner_model: str,
|
||||
writer_model: str,
|
||||
cloud_base_url: str | None = None,
|
||||
local_base_url: str | None = None,
|
||||
api_key: str | None = None,
|
||||
) -> None:
|
||||
self._secrets = secrets
|
||||
self._roles = ModelRoles(planner=planner_model, writer=writer_model)
|
||||
self._cloud_base = (cloud_base_url or secrets.resolve("OLLAMA_BASE_URL") or _DEFAULT_CLOUD_BASE).rstrip("/")
|
||||
self._local_base = (local_base_url or _DEFAULT_LOCAL_BASE).rstrip("/")
|
||||
self._active_base = self._cloud_base
|
||||
# Explicit api_key takes precedence over the secrets resolve
|
||||
# (T-2.5: the per-run factory passes the user's decrypted key);
|
||||
# the secrets path stays for back-compat (server tier).
|
||||
self._api_key = api_key if api_key is not None else secrets.resolve("OLLAMA_API_KEY")
|
||||
self._last_fallback_reason: str | None = None
|
||||
|
||||
def _headers(self, base_url: str) -> dict[str, str]:
|
||||
headers = {"Content-Type": "application/json"}
|
||||
if base_url == self._cloud_base and self._api_key:
|
||||
headers["Authorization"] = f"Bearer {self._api_key}"
|
||||
return headers
|
||||
|
||||
async def _post_chat(
|
||||
self,
|
||||
base_url: str,
|
||||
payload: dict[str, object],
|
||||
) -> dict[str, Any]:
|
||||
async with httpx.AsyncClient(timeout=_REQUEST_TIMEOUT) as client:
|
||||
response = await client.post(
|
||||
f"{base_url}/chat/completions",
|
||||
json=payload,
|
||||
headers=self._headers(base_url),
|
||||
)
|
||||
try:
|
||||
response.raise_for_status()
|
||||
except httpx.HTTPError as exc:
|
||||
raise AdapterError(f"chat request failed ({type(exc).__name__})") from exc
|
||||
data: dict[str, Any] = response.json()
|
||||
return data
|
||||
|
||||
async def chat(
|
||||
self,
|
||||
messages: list[ModelMessage],
|
||||
*,
|
||||
model: str,
|
||||
temperature: float = 0.2,
|
||||
json_mode: bool = False,
|
||||
) -> ChatResult:
|
||||
payload: dict[str, object] = {
|
||||
"model": model,
|
||||
"messages": [m.model_dump() for m in messages],
|
||||
"temperature": temperature,
|
||||
}
|
||||
if json_mode:
|
||||
payload["response_format"] = {"type": "json_object"}
|
||||
try:
|
||||
data = await self._post_chat(self._active_base, payload)
|
||||
except AdapterError as first_error:
|
||||
if self._active_base == self._cloud_base:
|
||||
self._last_fallback_reason = f"cloud endpoint failed: {type(first_error.__cause__).__name__ if first_error.__cause__ else 'Error'}"
|
||||
self._active_base = self._local_base
|
||||
data = await self._post_chat(self._local_base, payload)
|
||||
else:
|
||||
raise
|
||||
choice = data["choices"][0]["message"]
|
||||
raw_usage = data.get("usage") or {}
|
||||
usage = Usage(
|
||||
prompt_tokens=raw_usage.get("prompt_tokens"),
|
||||
completion_tokens=raw_usage.get("completion_tokens"),
|
||||
)
|
||||
return ChatResult(content=choice["content"], model=model, usage=usage)
|
||||
|
||||
async def list_models(self) -> list[str]:
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=_REQUEST_TIMEOUT) as client:
|
||||
response = await client.get(
|
||||
f"{self._active_base}/models",
|
||||
headers=self._headers(self._active_base),
|
||||
)
|
||||
try:
|
||||
response.raise_for_status()
|
||||
except httpx.HTTPError as exc:
|
||||
raise AdapterError(f"models listing failed ({type(exc).__name__})") from exc
|
||||
data: dict[str, Any] = response.json()
|
||||
return [str(entry["id"]) for entry in data.get("data", [])]
|
||||
except (AdapterError, httpx.HTTPError) as exc:
|
||||
# Fallback only if there IS a distinct local base to fall
|
||||
# back to (a caller pinning both bases to one endpoint —
|
||||
# e.g. a private gateway — must not recurse forever), and
|
||||
# never leak a raw httpx error: the adapter's outward
|
||||
# contract is a typed AdapterError.
|
||||
if self._active_base == self._cloud_base and self._local_base != self._cloud_base:
|
||||
self._last_fallback_reason = "cloud models listing failed; using local"
|
||||
self._active_base = self._local_base
|
||||
return await self.list_models()
|
||||
if isinstance(exc, AdapterError):
|
||||
raise
|
||||
raise AdapterError(f"models listing failed ({type(exc).__name__})") from exc
|
||||
|
||||
def roles(self) -> ModelRoles:
|
||||
return self._roles
|
||||
|
||||
@property
|
||||
def active_base_url(self) -> str:
|
||||
return self._active_base
|
||||
|
||||
@property
|
||||
def last_fallback_reason(self) -> str | None:
|
||||
return self._last_fallback_reason
|
||||
@@ -0,0 +1,34 @@
|
||||
from pydantic import BaseModel
|
||||
|
||||
DEFAULT_CONTEXT_CAP = 8192
|
||||
DEFAULT_EMBEDDING_BATCH = 8
|
||||
|
||||
|
||||
class CapabilityProbe(BaseModel):
|
||||
endpoint: str
|
||||
models: list[str]
|
||||
context_window: int
|
||||
embedding_batch: int
|
||||
structured_outputs_supported: bool
|
||||
structured_output_warning: str | None = None
|
||||
fallback_reason: str | None = None
|
||||
|
||||
@property
|
||||
def degraded(self) -> bool:
|
||||
return self.structured_output_warning is not None or self.fallback_reason is not None
|
||||
|
||||
|
||||
def cap_context_window(model_claim: int | None) -> int:
|
||||
if model_claim is None or model_claim <= 0:
|
||||
return DEFAULT_CONTEXT_CAP
|
||||
return min(model_claim, DEFAULT_CONTEXT_CAP)
|
||||
|
||||
|
||||
def structured_output_warning(supported: bool, model_name: str) -> str | None:
|
||||
if supported:
|
||||
return None
|
||||
return (
|
||||
f"Model '{model_name}' did not confirm structured-output (function-calling) support. "
|
||||
"Methodologies needing structured outputs will degrade to a prompt-embedded JSON "
|
||||
"contract — expect looser parsing, not a hard failure."
|
||||
)
|
||||
@@ -0,0 +1,10 @@
|
||||
from typing import Protocol, runtime_checkable
|
||||
|
||||
from researchx.adapters.types import PageContent, SearchResult
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class SearchAdapter(Protocol):
|
||||
async def search(self, query: str, *, max_results: int = 5) -> list[SearchResult]: ...
|
||||
|
||||
async def fetch(self, url: str) -> PageContent: ...
|
||||
@@ -0,0 +1,32 @@
|
||||
from pathlib import Path
|
||||
from typing import Protocol, runtime_checkable
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class SecretsProvider(Protocol):
|
||||
def resolve(self, name: str) -> str | None: ...
|
||||
|
||||
|
||||
def _parse_env_file(path: Path) -> dict[str, str]:
|
||||
values: dict[str, str] = {}
|
||||
for raw_line in path.read_text(encoding="utf-8").splitlines():
|
||||
line = raw_line.strip()
|
||||
if not line or line.startswith("#") or "=" not in line:
|
||||
continue
|
||||
key, _, value = line.partition("=")
|
||||
values[key.strip()] = value.strip().strip("'\"")
|
||||
return values
|
||||
|
||||
|
||||
class EnvFileSecretsProvider:
|
||||
def __init__(self, paths: list[Path]) -> None:
|
||||
self._paths = [p for p in paths if p.is_file()]
|
||||
self._values: dict[str, str] = {}
|
||||
for path in self._paths:
|
||||
self._values.update(_parse_env_file(path))
|
||||
|
||||
def resolve(self, name: str) -> str | None:
|
||||
return self._values.get(name)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"EnvFileSecretsProvider(paths={[p.name for p in self._paths]})"
|
||||
@@ -0,0 +1,13 @@
|
||||
from pathlib import Path
|
||||
from typing import Protocol, runtime_checkable
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class StorageAdapter(Protocol):
|
||||
async def write_run_artifact(self, run_id: str, name: str, content: str) -> Path: ...
|
||||
|
||||
async def read_run_artifact(self, run_id: str, name: str) -> str: ...
|
||||
|
||||
async def append_run_artifact(self, run_id: str, name: str, content: str) -> Path: ...
|
||||
|
||||
def run_dir(self, run_id: str) -> Path: ...
|
||||
@@ -0,0 +1,87 @@
|
||||
import re
|
||||
from datetime import UTC, datetime
|
||||
|
||||
import httpx
|
||||
|
||||
from researchx.adapters.secrets import SecretsProvider
|
||||
from researchx.adapters.types import AdapterError, PageContent, SearchResult
|
||||
from researchx.adapters.url_guard import url_allowed
|
||||
|
||||
_TAVILY_URL = "https://api.tavily.com/search"
|
||||
_SEARCH_TIMEOUT = 10.0
|
||||
_FETCH_TIMEOUT = 10.0
|
||||
_MAX_FETCH_REDIRECTS = 5
|
||||
_USER_AGENT = "researchx/0.2 (local research studio)"
|
||||
_TITLE_RE = re.compile(r"<title[^>]*>(.*?)</title>", re.IGNORECASE | re.DOTALL)
|
||||
_BLOCK_RE = re.compile(r"<(script|style)[^>]*>.*?</\1>", re.IGNORECASE | re.DOTALL)
|
||||
_TAG_RE = re.compile(r"<[^>]+>")
|
||||
|
||||
|
||||
def extract_text(html: str) -> str:
|
||||
text = _BLOCK_RE.sub(" ", html)
|
||||
text = _TAG_RE.sub(" ", text)
|
||||
text = re.sub(r"\s+", " ", text)
|
||||
return text.strip()
|
||||
|
||||
|
||||
def extract_title(html: str) -> str:
|
||||
match = _TITLE_RE.search(html)
|
||||
return match.group(1).strip() if match else ""
|
||||
|
||||
|
||||
class TavilySearchAdapter:
|
||||
def __init__(self, secrets: SecretsProvider) -> None:
|
||||
self._api_key = secrets.resolve("TAVILY_API_KEY")
|
||||
if not self._api_key:
|
||||
raise AdapterError("TAVILY_API_KEY not present in secrets scope")
|
||||
|
||||
async def search(self, query: str, *, max_results: int = 5) -> list[SearchResult]:
|
||||
payload = {
|
||||
"api_key": self._api_key,
|
||||
"query": query,
|
||||
"search_depth": "basic",
|
||||
"max_results": max_results,
|
||||
"include_answer": False,
|
||||
}
|
||||
async with httpx.AsyncClient(timeout=_SEARCH_TIMEOUT) as client:
|
||||
response = await client.post(_TAVILY_URL, json=payload)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
return [
|
||||
SearchResult(
|
||||
title=str(entry.get("title", "")),
|
||||
url=str(entry.get("url", "")),
|
||||
snippet=str(entry.get("content", "")),
|
||||
)
|
||||
for entry in data.get("results", [])
|
||||
][:max_results]
|
||||
|
||||
async def fetch(self, url: str) -> PageContent:
|
||||
from urllib.parse import urljoin
|
||||
|
||||
current = url
|
||||
async with httpx.AsyncClient(
|
||||
timeout=_FETCH_TIMEOUT,
|
||||
follow_redirects=False,
|
||||
headers={"User-Agent": _USER_AGENT},
|
||||
) as client:
|
||||
for _ in range(_MAX_FETCH_REDIRECTS + 1):
|
||||
if not url_allowed(current):
|
||||
raise AdapterError(
|
||||
"fetch refused: url not allowed (private or non-http scheme)"
|
||||
)
|
||||
response = await client.get(current)
|
||||
if 300 <= response.status_code < 400:
|
||||
location = response.headers.get("location")
|
||||
if not location:
|
||||
raise AdapterError("fetch failed: redirect without location")
|
||||
current = urljoin(current, location)
|
||||
continue
|
||||
response.raise_for_status()
|
||||
return PageContent(
|
||||
url=url,
|
||||
title=extract_title(response.text),
|
||||
text=extract_text(response.text),
|
||||
fetched_at=datetime.now(UTC),
|
||||
)
|
||||
raise AdapterError("fetch failed: too many redirects")
|
||||
@@ -0,0 +1,36 @@
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class ModelMessage(BaseModel):
|
||||
role: str
|
||||
content: str
|
||||
|
||||
|
||||
class Usage(BaseModel):
|
||||
prompt_tokens: int | None = None
|
||||
completion_tokens: int | None = None
|
||||
|
||||
|
||||
class ChatResult(BaseModel):
|
||||
content: str
|
||||
model: str
|
||||
usage: Usage | None = None
|
||||
|
||||
|
||||
class SearchResult(BaseModel):
|
||||
title: str
|
||||
url: str
|
||||
snippet: str
|
||||
|
||||
|
||||
class PageContent(BaseModel):
|
||||
url: str
|
||||
title: str
|
||||
text: str
|
||||
fetched_at: datetime
|
||||
|
||||
|
||||
class AdapterError(Exception):
|
||||
pass
|
||||
@@ -0,0 +1,44 @@
|
||||
import socket
|
||||
from ipaddress import IPv4Address, IPv6Address
|
||||
from urllib.parse import urlparse
|
||||
|
||||
|
||||
def _host_is_private(hostname: str) -> bool:
|
||||
try:
|
||||
addr_info = socket.getaddrinfo(hostname, None)
|
||||
except (socket.gaierror, UnicodeError):
|
||||
return True
|
||||
for family, _, _, _, sockaddr in addr_info:
|
||||
try:
|
||||
if family == socket.AF_INET6:
|
||||
ip: IPv4Address | IPv6Address = IPv6Address(sockaddr[0])
|
||||
else:
|
||||
ip = IPv4Address(sockaddr[0])
|
||||
except ValueError:
|
||||
return True
|
||||
if (
|
||||
ip.is_private
|
||||
or ip.is_loopback
|
||||
or ip.is_link_local
|
||||
or ip.is_reserved
|
||||
or ip.is_multicast
|
||||
or ip.is_unspecified
|
||||
):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def url_allowed(url: str) -> bool:
|
||||
parsed = urlparse(url)
|
||||
if parsed.scheme not in ("http", "https"):
|
||||
return False
|
||||
if not parsed.hostname:
|
||||
return False
|
||||
return not _host_is_private(parsed.hostname)
|
||||
|
||||
|
||||
class FetchRefusedError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
__all__ = ["FetchRefusedError", "url_allowed"]
|
||||
@@ -0,0 +1,719 @@
|
||||
import asyncio
|
||||
import time
|
||||
from collections.abc import AsyncIterator
|
||||
from pathlib import Path
|
||||
from typing import Annotated, Any
|
||||
|
||||
from fastapi import APIRouter, Depends, FastAPI, HTTPException
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from pydantic import BaseModel, Field, ValidationError
|
||||
from sse_starlette.sse import EventSourceResponse
|
||||
|
||||
from researchx.adapters.capability_probe import probe_model_adapter, supports_probe
|
||||
from researchx.adapters.fakes import CORPUS, FakeModelAdapter, FakeSearchAdapter
|
||||
from researchx.adapters.local_storage import LocalStorageAdapter
|
||||
from researchx.adapters.model_factory import ModelAdapterFactory
|
||||
from researchx.adapters.types import AdapterError, ChatResult
|
||||
from researchx.auth.admin_routes import build_admin_router
|
||||
from researchx.auth.guard import Guard, build_admin_guard, build_dev_guard, build_guard
|
||||
from researchx.auth.key_store import KEY_SCOPE, KeyStore, KeyStoreError
|
||||
from researchx.auth.models import UserPublic
|
||||
from researchx.auth.routes import build_auth_router
|
||||
from researchx.auth.sessions import SessionStore
|
||||
from researchx.auth.settings import SettingsStore, SettingsStoreError, UserSettings
|
||||
from researchx.auth.users import UserStore
|
||||
from researchx.config.paths import data_root
|
||||
from researchx.engine.actor import RunActor, make_run_config
|
||||
from researchx.engine.events import EventBus, RunEvent
|
||||
from researchx.engine.run_state import Plan, RunState
|
||||
from researchx.engine.states import TERMINAL_STATES, RunStateName
|
||||
from researchx.methodologies import registry
|
||||
|
||||
_CANNED_PLAN = (
|
||||
'{"sub_questions": ["What is the research question?"], '
|
||||
'"source_universe": ["web"], "outline": ["approach", "findings", "conclusion"]}'
|
||||
)
|
||||
_CANNED_WRITER = (
|
||||
"# Research Memo\n\n"
|
||||
"The verified core finding [p-1]. A second corroborated observation [p-2].\n"
|
||||
)
|
||||
|
||||
_CORS_ORIGINS = ["http://localhost:3000", "http://127.0.0.1:3000"]
|
||||
|
||||
# Config-status probe cache TTL (P-1): in-process, keyed
|
||||
# (user_id, tier, key_fingerprint) so a key change invalidates.
|
||||
_PROBE_CACHE_TTL_SECONDS = 60.0
|
||||
|
||||
|
||||
class CreateRunBody(BaseModel):
|
||||
question: str = Field(min_length=1, max_length=2000)
|
||||
context: str = Field(default="", max_length=8000)
|
||||
methodology: str
|
||||
dial_overrides: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class ApproveBody(BaseModel):
|
||||
plan: dict[str, Any] | None = None
|
||||
|
||||
|
||||
class SettingsBody(BaseModel):
|
||||
"""PUT /api/config/settings body — partial update (D-111)."""
|
||||
|
||||
planner_model: str | None = Field(default=None, min_length=1, max_length=200)
|
||||
writer_model: str | None = Field(default=None, min_length=1, max_length=200)
|
||||
methodology_defaults: dict[str, Any] | None = None
|
||||
appearance: str | None = Field(default=None, max_length=40)
|
||||
|
||||
|
||||
class ModelKeyBody(BaseModel):
|
||||
"""PUT /api/config/settings/model-key body — the raw key, accepted,
|
||||
never echoed (REQ-73)."""
|
||||
|
||||
key: str = Field(min_length=1, max_length=512)
|
||||
|
||||
|
||||
class KeyStatus(BaseModel):
|
||||
"""Masked-only key status — the raw key NEVER appears (REQ-73)."""
|
||||
|
||||
configured: bool
|
||||
masked: str | None = None
|
||||
disabled: bool = False
|
||||
reason: str | None = None
|
||||
|
||||
|
||||
class ConfigStatus(BaseModel):
|
||||
resolved_tier: str # user | server | keyless (REQ-74)
|
||||
key_store: str # enabled | disabled
|
||||
key_status: KeyStatus
|
||||
model_adapter: str
|
||||
search_adapter: str
|
||||
storage_dir: str
|
||||
probe: dict[str, Any] | None = None
|
||||
cost_counters: dict[str, int]
|
||||
|
||||
|
||||
class RunSummary(BaseModel):
|
||||
id: str
|
||||
status: str
|
||||
question: str
|
||||
methodology: str
|
||||
plan: dict[str, Any] | None = None
|
||||
started_at: str
|
||||
|
||||
|
||||
class MethodologyCard(BaseModel):
|
||||
slug: str
|
||||
name: str
|
||||
description: str
|
||||
eta_minutes: int
|
||||
cost_estimate: str
|
||||
why_recommended: str
|
||||
output_shape: str
|
||||
dials: dict[str, Any]
|
||||
|
||||
|
||||
_STATUS_MAP = {
|
||||
RunStateName.PLANNING: "planning",
|
||||
RunStateName.AWAITING_APPROVAL: "awaiting_approval",
|
||||
RunStateName.SEARCHING: "searching",
|
||||
RunStateName.EXTRACTING: "extracting",
|
||||
RunStateName.REFLECTING: "reflecting",
|
||||
RunStateName.SYNTHESIZING: "synthesizing",
|
||||
RunStateName.VERIFYING: "verifying",
|
||||
RunStateName.DONE: "done",
|
||||
RunStateName.CANCELLED: "cancelled",
|
||||
RunStateName.FAILED: "failed",
|
||||
}
|
||||
|
||||
_ETA_TABLE = {"rapid-answer": (2, "low"), "deep-report": (12, "medium")}
|
||||
|
||||
|
||||
def _cost_estimate(slug: str) -> str:
|
||||
methodology = registry.get(slug)
|
||||
units = methodology.dials.depth * methodology.dials.breadth
|
||||
if units <= 1:
|
||||
return "~5k tokens (estimate)"
|
||||
if units <= 4:
|
||||
return "~25k tokens (estimate)"
|
||||
return "~60k tokens (estimate)"
|
||||
|
||||
|
||||
def _why_recommended(slug: str) -> str:
|
||||
return {
|
||||
"rapid-answer": "One pass, one memo — fastest path to a confident, cited answer.",
|
||||
"deep-report": "Iterative depth with full verification — when the answer must stand up to scrutiny.",
|
||||
}.get(slug, "Methodology-first research.")
|
||||
|
||||
|
||||
class RunRegistry:
|
||||
"""In-memory live-run registry, keyed (owner_id, run_id) (REQ-71).
|
||||
|
||||
Cross-owner access is indistinguishable from a missing run: the
|
||||
keyed lookup misses, routes translate the miss to a 404. Single
|
||||
uvicorn worker only — this state is single-process by design.
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._actors: dict[tuple[str, str], tuple[RunActor, asyncio.Task[RunState]]] = {}
|
||||
self._buses: dict[tuple[str, str], EventBus] = {}
|
||||
# Live SSE subscriber refcounts, keyed like _actors (P-6).
|
||||
self._subscribers: dict[tuple[str, str], int] = {}
|
||||
# Owner-keyed cost counters (P-13): per-user usage surface.
|
||||
self.cost_counters: dict[str, dict[str, int]] = {}
|
||||
|
||||
def _counters(self, owner_id: str) -> dict[str, int]:
|
||||
return self.cost_counters.setdefault(owner_id, {"runs_created": 0, "runs_completed": 0})
|
||||
|
||||
def add(
|
||||
self,
|
||||
owner_id: str,
|
||||
run_id: str,
|
||||
actor: RunActor,
|
||||
task: asyncio.Task[RunState],
|
||||
bus: EventBus,
|
||||
) -> None:
|
||||
self._actors[(owner_id, run_id)] = (actor, task)
|
||||
self._buses[(owner_id, run_id)] = bus
|
||||
self._counters(owner_id)["runs_created"] += 1
|
||||
|
||||
def actor(self, owner_id: str, run_id: str) -> RunActor:
|
||||
entry = self._actors.get((owner_id, run_id))
|
||||
if entry is None:
|
||||
raise KeyError((owner_id, run_id))
|
||||
return entry[0]
|
||||
|
||||
def bus(self, owner_id: str, run_id: str) -> EventBus:
|
||||
bus = self._buses.get((owner_id, run_id))
|
||||
if bus is None:
|
||||
raise KeyError((owner_id, run_id))
|
||||
return bus
|
||||
|
||||
def has(self, owner_id: str, run_id: str) -> bool:
|
||||
return (owner_id, run_id) in self._actors
|
||||
|
||||
def note_completed(self, owner_id: str) -> None:
|
||||
self._counters(owner_id)["runs_completed"] += 1
|
||||
|
||||
def completed_snapshot(self, owner_id: str) -> dict[str, int]:
|
||||
"""Current counters for *owner_id*, derived where possible.
|
||||
|
||||
``runs_completed`` is computed from the live registry (a run is
|
||||
completed iff its actor sits in a terminal state) and merged
|
||||
with the sticky counter — which keeps counting runs that have
|
||||
since been pruned from the registry. Deriving from actor state
|
||||
removes the scheduling race between the actor finishing and a
|
||||
detached watcher task incrementing the counter.
|
||||
"""
|
||||
counters = dict(self._counters(owner_id))
|
||||
live_done = sum(
|
||||
1
|
||||
for (owner, _), (actor, _) in self._actors.items()
|
||||
if owner == owner_id and actor.state.state == RunStateName.DONE
|
||||
)
|
||||
if live_done:
|
||||
# Prefer live truth when registry entries still exist; the
|
||||
# sticky counter catches pruned/evicted runs afterwards.
|
||||
counters["runs_completed"] = max(counters["runs_completed"], live_done)
|
||||
return counters
|
||||
|
||||
def subscribe_events(self, owner_id: str, run_id: str) -> None:
|
||||
self._subscribers[(owner_id, run_id)] = self._subscribers.get((owner_id, run_id), 0) + 1
|
||||
|
||||
def unsubscribe_events(self, owner_id: str, run_id: str) -> None:
|
||||
count = self._subscribers.get((owner_id, run_id), 0)
|
||||
if count <= 1:
|
||||
self._subscribers.pop((owner_id, run_id), None)
|
||||
else:
|
||||
self._subscribers[(owner_id, run_id)] = count - 1
|
||||
|
||||
def subscriber_count(self, owner_id: str, run_id: str) -> int:
|
||||
return self._subscribers.get((owner_id, run_id), 0)
|
||||
|
||||
def prune(self, owner_id: str, run_id: str) -> bool:
|
||||
"""Drop a terminal entry with zero live SSE subscribers (P-6).
|
||||
|
||||
Terminal entries are otherwise a slow multi-user leak. Events
|
||||
for a pruned run 404 afterwards — documented acceptable; the
|
||||
trace persists on disk.
|
||||
"""
|
||||
entry = self._actors.get((owner_id, run_id))
|
||||
if entry is None:
|
||||
return False
|
||||
actor = entry[0]
|
||||
if actor.state.state not in TERMINAL_STATES:
|
||||
return False
|
||||
if self.subscriber_count(owner_id, run_id) > 0:
|
||||
return False
|
||||
self._actors.pop((owner_id, run_id), None)
|
||||
self._buses.pop((owner_id, run_id), None)
|
||||
self._subscribers.pop((owner_id, run_id), None)
|
||||
return True
|
||||
|
||||
|
||||
def create_app(
|
||||
*,
|
||||
storage_dir: Path | None = None,
|
||||
model_adapter: Any = None,
|
||||
search_adapter: Any = None,
|
||||
auth_enabled: bool = True,
|
||||
cookie_secure: bool = False,
|
||||
data_dir: Path | None = None,
|
||||
key_store: KeyStore | None = None,
|
||||
settings_store: SettingsStore | None = None,
|
||||
model_factory: ModelAdapterFactory | None = None,
|
||||
secrets_provider: Any = None,
|
||||
) -> FastAPI:
|
||||
"""Build the ResearchX API app.
|
||||
|
||||
``auth_enabled=False`` is a dev/test harness parameter ONLY — never
|
||||
an env var readable in production (C3-16). It resolves a synthetic
|
||||
dev admin for every request so zero-key suites keep working.
|
||||
``data_dir`` overrides the auth-store location (defaults to
|
||||
``data_root()``); ``storage_dir`` remains the run-artifact base.
|
||||
|
||||
Per-user stores (T-2.2/T-2.3): ``key_store`` defaults to a KeyStore
|
||||
over the data root (disabled when no KEK is configured — honest
|
||||
degradation); ``settings_store`` likewise. Both are injectable for
|
||||
tests. ``model_factory`` (T-2.5) resolves the per-user credential
|
||||
chain user → server → keyless for ``create_run`` and
|
||||
``GET /api/config``; when absent the fixed ``model_adapter`` is
|
||||
used (test harness), tiered resolution disabled by construction.
|
||||
"""
|
||||
app = FastAPI(title="ResearchX API", version="0.2.0")
|
||||
|
||||
# D-107: run artifacts live under the data root (storage_dir
|
||||
# overrides for tests); auth stores always share the same root.
|
||||
storage_base = storage_dir if storage_dir is not None else data_root()
|
||||
runs = RunRegistry()
|
||||
# The fallback model is constructed ONLY when no factory is wired:
|
||||
# the factory path (production, T-2.5) resolves a real adapter per
|
||||
# run and the production entrypoint can therefore never hold a
|
||||
# FakeModelAdapter in memory (P-3).
|
||||
model: Any
|
||||
if model_adapter is not None:
|
||||
model = model_adapter
|
||||
elif model_factory is not None:
|
||||
model = None # every consumer goes through the factory
|
||||
else:
|
||||
model = FakeModelAdapter(
|
||||
canned=[
|
||||
ChatResult(content=_CANNED_PLAN, model="fake-planner"),
|
||||
ChatResult(content=_CANNED_WRITER, model="fake-writer"),
|
||||
]
|
||||
)
|
||||
search = search_adapter or FakeSearchAdapter(CORPUS)
|
||||
|
||||
auth_root = data_dir if data_dir is not None else data_root()
|
||||
users = UserStore(auth_root / "users.json")
|
||||
sessions = SessionStore(auth_root / "sessions.json")
|
||||
|
||||
# Per-user stores wired from the same root as the auth stores;
|
||||
# injectable for tests (key_store=None -> disabled, honest reason).
|
||||
key_store = key_store if key_store is not None else KeyStore(
|
||||
secrets_provider or _NullSecrets(), auth_root
|
||||
)
|
||||
settings_store = settings_store if settings_store is not None else SettingsStore(auth_root)
|
||||
|
||||
guard: Guard = build_guard(users, sessions) if auth_enabled else build_dev_guard()
|
||||
admin_guard = build_admin_guard(guard)
|
||||
|
||||
# In-process probe cache keyed (user_id, tier, key_fingerprint)
|
||||
# with a short TTL (P-1 closure — the probe runs against the
|
||||
# RESOLVED tier, never stolen from the registry, and a key change
|
||||
# rotates the fingerprint which invalidates the entry).
|
||||
probe_cache: dict[tuple[str, str, str], tuple[float, dict[str, Any] | None]] = {}
|
||||
|
||||
def _owner_storage(user: UserPublic) -> LocalStorageAdapter:
|
||||
"""Owner-scoped storage, constructed per request from the auth
|
||||
dependency (REQ-71): runs/<user_id>/<run_id>/."""
|
||||
return LocalStorageAdapter(base_dir=storage_base, owner_id=user.user_id)
|
||||
|
||||
def _summary(user: UserPublic, run_id: str) -> RunSummary:
|
||||
actor = runs.actor(user.user_id, run_id)
|
||||
state = actor.state
|
||||
return RunSummary(
|
||||
id=run_id,
|
||||
status=_STATUS_MAP[state.state],
|
||||
question=state.question,
|
||||
methodology=state.methodology_slug,
|
||||
plan=state.plan.model_dump(mode="json") if state.plan else None,
|
||||
started_at=state.created_at.isoformat(),
|
||||
)
|
||||
|
||||
async def _masked_key_status(user: UserPublic) -> KeyStatus:
|
||||
"""Masked-only key status — never the raw key (REQ-73)."""
|
||||
try:
|
||||
status = await key_store.masked_status(user.user_id)
|
||||
except KeyStoreError as exc:
|
||||
# Stored-but-unreadable: surface the error state honestly.
|
||||
return KeyStatus(
|
||||
configured=False, masked=None, disabled=not key_store.enabled, reason=str(exc)
|
||||
)
|
||||
masked = status.get("masked")
|
||||
reason = status.get("reason")
|
||||
return KeyStatus(
|
||||
configured=bool(status.get("configured")),
|
||||
masked=str(masked) if masked is not None else None,
|
||||
disabled=bool(status.get("disabled")),
|
||||
reason=str(reason) if reason is not None else None,
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# PUBLIC surface: /healthz outside /api (D-103 — plain GET for the
|
||||
# TLS-front proxy, never auth-wrapped) + /api/auth/* (no
|
||||
# require_user; the auth router enforces the CSRF header on its
|
||||
# own POSTs).
|
||||
# ------------------------------------------------------------------
|
||||
public = build_auth_router(users, sessions, cookie_secure=cookie_secure)
|
||||
|
||||
@app.get("/healthz")
|
||||
async def healthz() -> dict[str, str]:
|
||||
return {"status": "ok"}
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# PROTECTED router: every /api business route carries the guard.
|
||||
# Admin user management additionally carries the admin guard.
|
||||
# ------------------------------------------------------------------
|
||||
protected = APIRouter(dependencies=[Depends(guard)])
|
||||
|
||||
protected.include_router(build_admin_router(users, sessions, admin_guard))
|
||||
|
||||
@protected.get("/api/config", response_model=ConfigStatus)
|
||||
async def config_status(user: Annotated[UserPublic, Depends(guard)]) -> ConfigStatus:
|
||||
"""Current user's resolved credential tier + status (T-2.6).
|
||||
|
||||
P-1 closure: the probe is resolved via the factory for the
|
||||
CURRENT user — no run creation, no reads of the private
|
||||
registry dict, correct with zero runs, no cross-user leak.
|
||||
A corrupt/tampered stored envelope degrades the tier honestly
|
||||
(server/keyless) and surfaces the error in key_status.reason —
|
||||
never a 500, never a silent drop.
|
||||
"""
|
||||
tier = "keyless"
|
||||
adapter_name = type(model).__name__ if model is not None else "unknown"
|
||||
probe: dict[str, Any] | None = None
|
||||
resolve_error: str | None = None
|
||||
|
||||
if model_factory is not None:
|
||||
settings = await settings_store.get(user.user_id)
|
||||
adapter, tier, resolve_error = await model_factory.resolve_status(user, settings)
|
||||
adapter_name = type(adapter).__name__
|
||||
fingerprint = await model_factory.key_fingerprint(user)
|
||||
cache_key = (user.user_id, tier, fingerprint)
|
||||
now = time.monotonic()
|
||||
cached = probe_cache.get(cache_key)
|
||||
if cached is not None and now - cached[0] < _PROBE_CACHE_TTL_SECONDS:
|
||||
probe = cached[1]
|
||||
elif supports_probe(adapter):
|
||||
try:
|
||||
probe_result = await probe_model_adapter(adapter)
|
||||
probe = probe_result.model_dump(mode="json")
|
||||
probe_cache[cache_key] = (now, probe)
|
||||
except AdapterError:
|
||||
probe = None
|
||||
probe_cache[cache_key] = (now, None)
|
||||
key_status = await _masked_key_status(user)
|
||||
if resolve_error is not None and key_status.reason is None:
|
||||
# The tier degraded honestly; say WHY (never silently).
|
||||
key_status = key_status.model_copy(update={"reason": resolve_error})
|
||||
return ConfigStatus(
|
||||
resolved_tier=tier,
|
||||
key_store="enabled" if key_store.enabled else "disabled",
|
||||
key_status=key_status,
|
||||
model_adapter=adapter_name,
|
||||
search_adapter=type(search).__name__,
|
||||
storage_dir=str(storage_base),
|
||||
probe=probe,
|
||||
cost_counters=runs.completed_snapshot(user.user_id),
|
||||
)
|
||||
|
||||
@protected.get("/api/config/settings", response_model=UserSettings)
|
||||
async def get_settings(user: Annotated[UserPublic, Depends(guard)]) -> UserSettings:
|
||||
return await settings_store.get(user.user_id)
|
||||
|
||||
@protected.put("/api/config/settings", response_model=UserSettings)
|
||||
async def put_settings(
|
||||
body: SettingsBody, user: Annotated[UserPublic, Depends(guard)]
|
||||
) -> UserSettings:
|
||||
"""Persist a partial settings update (REQ-72); returns the
|
||||
updated settings. An explicit null field is treated as absent
|
||||
(partial-merge semantics — the store refuses null-valued
|
||||
fields, so a null can never poison the on-disk payload)."""
|
||||
updates: dict[str, Any] = {
|
||||
key: value
|
||||
for key, value in body.model_dump(exclude_unset=True).items()
|
||||
if value is not None
|
||||
}
|
||||
try:
|
||||
current = await settings_store.get(user.user_id)
|
||||
merged = current.model_copy(update=updates)
|
||||
return await settings_store.update(user.user_id, merged)
|
||||
except SettingsStoreError as exc:
|
||||
raise HTTPException(status_code=500, detail=str(exc)) from exc
|
||||
|
||||
@protected.put("/api/config/settings/model-key", response_model=KeyStatus)
|
||||
async def put_model_key(
|
||||
body: ModelKeyBody, user: Annotated[UserPublic, Depends(guard)]
|
||||
) -> KeyStatus:
|
||||
"""Accept the user's raw model key, encrypt it at rest, return
|
||||
ONLY the masked status (REQ-73 — the raw key never appears in
|
||||
any response, log, or trace). Without a KEK: honest 409."""
|
||||
if not key_store.enabled:
|
||||
# Honest refusal, never a silent no-op.
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail={
|
||||
"key_store": "disabled",
|
||||
"reason": key_store.disabled_reason,
|
||||
},
|
||||
)
|
||||
await key_store.set_key(user.user_id, KEY_SCOPE, body.key)
|
||||
return await _masked_key_status(user)
|
||||
|
||||
@protected.get("/api/config/settings/model-key", response_model=KeyStatus)
|
||||
async def get_model_key_status(user: Annotated[UserPublic, Depends(guard)]) -> KeyStatus:
|
||||
"""Masked-only key status (never the raw key)."""
|
||||
return await _masked_key_status(user)
|
||||
|
||||
@protected.get("/api/methodologies")
|
||||
async def methodologies() -> list[MethodologyCard]:
|
||||
cards: list[MethodologyCard] = []
|
||||
for methodology in registry.list():
|
||||
eta_minutes, _ = _ETA_TABLE.get(methodology.slug, (5, "low"))
|
||||
cards.append(
|
||||
MethodologyCard(
|
||||
slug=methodology.slug,
|
||||
name=methodology.name,
|
||||
description=methodology.description,
|
||||
eta_minutes=eta_minutes,
|
||||
cost_estimate=_cost_estimate(methodology.slug),
|
||||
why_recommended=_why_recommended(methodology.slug),
|
||||
output_shape=methodology.output_shape,
|
||||
dials=methodology.dials.model_dump(mode="json"),
|
||||
)
|
||||
)
|
||||
return cards
|
||||
|
||||
@protected.post("/api/runs", status_code=201)
|
||||
async def create_run(body: CreateRunBody, user: Annotated[UserPublic, Depends(guard)]) -> RunSummary:
|
||||
if body.methodology not in registry:
|
||||
raise HTTPException(status_code=404, detail="unknown methodology")
|
||||
storage = _owner_storage(user)
|
||||
|
||||
# Per-run credential resolution (T-2.5, REQ-74): one adapter
|
||||
# instance per run — the _active_base fallback mutation is
|
||||
# per-instance by construction (P-2 closure).
|
||||
tier: str | None = None
|
||||
if model_factory is not None:
|
||||
settings = await settings_store.get(user.user_id)
|
||||
try:
|
||||
run_model, tier = await model_factory.resolve(user, settings)
|
||||
except KeyStoreError as exc:
|
||||
# A tampered/corrupt stored key is an honest failure —
|
||||
# never a silent tier drop and never a wrong-key run.
|
||||
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
||||
else:
|
||||
# No factory: the fixed test-harness adapter (create_app
|
||||
# guarantees one exists whenever the factory is absent).
|
||||
run_model = model
|
||||
|
||||
try:
|
||||
run_id, config = make_run_config(
|
||||
question=body.question,
|
||||
context=body.context,
|
||||
methodology_slug=body.methodology,
|
||||
overrides=body.dial_overrides,
|
||||
registry=registry,
|
||||
owner_id=user.user_id,
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=422, detail=str(exc)) from exc
|
||||
except ValidationError as exc:
|
||||
raise HTTPException(status_code=422, detail="invalid dial override") from exc
|
||||
bus = EventBus()
|
||||
actor = RunActor(
|
||||
run_config=config,
|
||||
model=run_model,
|
||||
search=search,
|
||||
storage=storage,
|
||||
registry=registry,
|
||||
event_bus=bus,
|
||||
)
|
||||
# Stamp the owner on the persisted state (REQ-71). The actor
|
||||
# itself is untouched — engine contracts unchanged.
|
||||
actor.state.owner_id = user.user_id
|
||||
actor.state.credential_tier = tier # REQ-74: auditability
|
||||
if supports_probe(run_model):
|
||||
try:
|
||||
probe = await probe_model_adapter(run_model)
|
||||
await actor.attach_probe(probe.model_dump(mode="json"))
|
||||
except AdapterError:
|
||||
pass
|
||||
|
||||
task = asyncio.create_task(actor.run())
|
||||
runs.add(user.user_id, run_id, actor, task, bus)
|
||||
|
||||
async def _watch() -> None:
|
||||
state = await task
|
||||
if state.state == RunStateName.DONE:
|
||||
runs.note_completed(user.user_id)
|
||||
|
||||
asyncio.create_task(_watch())
|
||||
return _summary(user, run_id)
|
||||
|
||||
@protected.get("/api/runs/{run_id}")
|
||||
async def get_run(run_id: str, user: Annotated[UserPublic, Depends(guard)]) -> RunSummary:
|
||||
if not runs.has(user.user_id, run_id):
|
||||
raise HTTPException(status_code=404, detail="run not found")
|
||||
return _summary(user, run_id)
|
||||
|
||||
@protected.post("/api/runs/{run_id}/approve")
|
||||
async def approve_run(
|
||||
run_id: str,
|
||||
user: Annotated[UserPublic, Depends(guard)],
|
||||
body: ApproveBody | None = None,
|
||||
) -> RunSummary:
|
||||
if not runs.has(user.user_id, run_id):
|
||||
raise HTTPException(status_code=404, detail="run not found")
|
||||
actor = runs.actor(user.user_id, run_id)
|
||||
edited_plan: Plan | None = None
|
||||
if body and body.plan:
|
||||
try:
|
||||
edited_plan = Plan.model_validate(body.plan)
|
||||
except ValidationError:
|
||||
raise HTTPException(status_code=422, detail="invalid plan")
|
||||
await actor.approve(edited_plan)
|
||||
return _summary(user, run_id)
|
||||
|
||||
@protected.post("/api/runs/{run_id}/pause")
|
||||
async def pause_run(run_id: str, user: Annotated[UserPublic, Depends(guard)]) -> RunSummary:
|
||||
if not runs.has(user.user_id, run_id):
|
||||
raise HTTPException(status_code=404, detail="run not found")
|
||||
runs.actor(user.user_id, run_id).pause()
|
||||
return _summary(user, run_id)
|
||||
|
||||
@protected.post("/api/runs/{run_id}/resume")
|
||||
async def resume_run(run_id: str, user: Annotated[UserPublic, Depends(guard)]) -> RunSummary:
|
||||
if not runs.has(user.user_id, run_id):
|
||||
raise HTTPException(status_code=404, detail="run not found")
|
||||
runs.actor(user.user_id, run_id).resume()
|
||||
return _summary(user, run_id)
|
||||
|
||||
@protected.post("/api/runs/{run_id}/cancel")
|
||||
async def cancel_run(run_id: str, user: Annotated[UserPublic, Depends(guard)]) -> RunSummary:
|
||||
if not runs.has(user.user_id, run_id):
|
||||
raise HTTPException(status_code=404, detail="run not found")
|
||||
actor = runs.actor(user.user_id, run_id)
|
||||
actor.cancel()
|
||||
actor.resume()
|
||||
return _summary(user, run_id)
|
||||
|
||||
@protected.get("/api/runs/{run_id}/events")
|
||||
async def run_events(
|
||||
run_id: str, user: Annotated[UserPublic, Depends(guard)]
|
||||
) -> EventSourceResponse:
|
||||
if not runs.has(user.user_id, run_id):
|
||||
raise HTTPException(status_code=404, detail="run not found")
|
||||
bus = runs.bus(user.user_id, run_id)
|
||||
actor = runs.actor(user.user_id, run_id)
|
||||
terminal = TERMINAL_STATES
|
||||
|
||||
async def event_stream() -> AsyncIterator[dict[str, str]]:
|
||||
queue: asyncio.Queue[RunEvent] = asyncio.Queue()
|
||||
|
||||
async def forward(event: RunEvent) -> None:
|
||||
await queue.put(event)
|
||||
|
||||
runs.subscribe_events(user.user_id, run_id) # SSE refcount (P-6)
|
||||
bus.subscribe(forward)
|
||||
try:
|
||||
replay = bus.replay()
|
||||
watermark = replay[-1].seq if replay else 0
|
||||
for prior in replay:
|
||||
yield {"event": prior.type, "data": prior.model_dump_json()}
|
||||
while True:
|
||||
if actor.state.state in terminal:
|
||||
try:
|
||||
event = await asyncio.wait_for(queue.get(), timeout=0.5)
|
||||
except TimeoutError:
|
||||
return
|
||||
else:
|
||||
event = await queue.get()
|
||||
if event.seq <= watermark:
|
||||
continue
|
||||
yield {"event": event.type, "data": event.model_dump_json()}
|
||||
if event.type in ("run_done", "run_error"):
|
||||
return
|
||||
finally:
|
||||
bus.unsubscribe(forward)
|
||||
runs.unsubscribe_events(user.user_id, run_id)
|
||||
# Registry pruning (P-6): a terminal run whose last SSE
|
||||
# subscriber just left is dropped from the registry.
|
||||
# Events for a pruned run 404 afterwards — documented
|
||||
# acceptable; the trace persists on disk.
|
||||
runs.prune(user.user_id, run_id)
|
||||
|
||||
return EventSourceResponse(event_stream())
|
||||
|
||||
@protected.get("/api/runs/{run_id}/report")
|
||||
async def run_report(run_id: str, user: Annotated[UserPublic, Depends(guard)]) -> dict[str, Any]:
|
||||
if not runs.has(user.user_id, run_id):
|
||||
raise HTTPException(status_code=404, detail="run not found")
|
||||
try:
|
||||
content = await _owner_storage(user).read_run_artifact(run_id, "report.md")
|
||||
except AdapterError:
|
||||
raise HTTPException(status_code=404, detail="report not ready")
|
||||
return {"run_id": run_id, "markdown": content}
|
||||
|
||||
@protected.get("/api/runs/{run_id}/ledger")
|
||||
async def run_ledger(run_id: str, user: Annotated[UserPublic, Depends(guard)]) -> dict[str, Any]:
|
||||
if not runs.has(user.user_id, run_id):
|
||||
raise HTTPException(status_code=404, detail="run not found")
|
||||
try:
|
||||
content = await _owner_storage(user).read_run_artifact(run_id, "ledger.json")
|
||||
except AdapterError:
|
||||
raise HTTPException(status_code=404, detail="ledger not ready")
|
||||
import json
|
||||
|
||||
data: dict[str, Any] = json.loads(content)
|
||||
return data
|
||||
|
||||
app.include_router(public)
|
||||
app.include_router(protected)
|
||||
|
||||
# Startup sweep (D-115, G-101): every non-terminal state.json under
|
||||
# the storage base is rewritten as CANCELLED — in-flight actors died
|
||||
# with the previous process; rehydration is out of v0.3 scope. Runs
|
||||
# synchronously at app construction so the very first request
|
||||
# already sees a consistent disk.
|
||||
from researchx.api.sweep import sweep_in_flight_runs
|
||||
|
||||
sweep_in_flight_runs(storage_base)
|
||||
|
||||
# CORS is added AFTER the routers so it is the OUTERMOST middleware
|
||||
# (starlette builds the stack in add-order: last added = outermost)
|
||||
# — preflight OPTIONS is answered without any auth 401 (P-4/C3-04).
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=_CORS_ORIGINS,
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
return app
|
||||
|
||||
|
||||
class _NullSecrets:
|
||||
"""No-secrets provider: the default key store is disabled unless a
|
||||
secrets provider is injected (zero-key test harness stays green)."""
|
||||
|
||||
def resolve(self, name: str) -> str | None:
|
||||
return None
|
||||
|
||||
|
||||
# Dev-only module app (README's `uvicorn researchx.api.app:app`).
|
||||
# Pitfall P-12/§8 item 3: this serves FAKES with default storage —
|
||||
# the production entrypoint (researchx.api.main, Phase 2) must never
|
||||
# import this global. Single worker only (in-memory stores by design).
|
||||
app = create_app()
|
||||
@@ -0,0 +1,91 @@
|
||||
"""Dev bridge: serve the FastAPI app on a loopback port for E2E tests.
|
||||
|
||||
Prints READY on stdout once the server is listening; SIGTERM shuts
|
||||
down cleanly. Single-user, loopback-only, disposable — for tests and
|
||||
local dev. Uses an inexhaustible scripted model so many sequential
|
||||
runs work without restocking canned responses.
|
||||
"""
|
||||
|
||||
import contextlib
|
||||
import json
|
||||
import sys
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import uvicorn
|
||||
|
||||
from researchx.adapters.fakes import CORPUS, FakeSearchAdapter
|
||||
from researchx.adapters.types import ChatResult, ModelMessage
|
||||
from researchx.api.app import create_app
|
||||
|
||||
_HOST = "127.0.0.1"
|
||||
_PORT = 8000
|
||||
|
||||
_PLAN = json.dumps(
|
||||
{
|
||||
"sub_questions": ["verification passes"],
|
||||
"source_universe": ["web"],
|
||||
"outline": ["approach", "findings"],
|
||||
}
|
||||
)
|
||||
_WRITER = (
|
||||
"# Research Memo\n\n"
|
||||
"The verified core finding [p-1]. A corroborated observation [p-2].\n"
|
||||
)
|
||||
|
||||
|
||||
class EndlessModelAdapter:
|
||||
def __init__(self) -> None:
|
||||
self.calls = 0
|
||||
|
||||
async def chat(
|
||||
self,
|
||||
messages: list[ModelMessage],
|
||||
*,
|
||||
model: str,
|
||||
temperature: float = 0.2,
|
||||
json_mode: bool = False,
|
||||
) -> ChatResult:
|
||||
del messages, temperature
|
||||
self.calls += 1
|
||||
if json_mode:
|
||||
return ChatResult(content=_PLAN, model=model)
|
||||
return ChatResult(content=_WRITER, model=model)
|
||||
|
||||
async def list_models(self) -> list[str]:
|
||||
return ["endless-model"]
|
||||
|
||||
def roles(self) -> Any:
|
||||
from researchx.adapters.model import ModelRoles
|
||||
|
||||
return ModelRoles(planner="endless-model", writer="endless-model")
|
||||
|
||||
|
||||
def main() -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
# Dev/E2E bridge: auth explicitly OFF (P-5) — a loopback-only,
|
||||
# disposable harness. Never the production entrypoint; the
|
||||
# production server (researchx.api.main, Phase 2) never passes
|
||||
# auth_enabled.
|
||||
app = create_app(
|
||||
storage_dir=Path(tmp),
|
||||
model_adapter=EndlessModelAdapter(),
|
||||
search_adapter=FakeSearchAdapter(CORPUS),
|
||||
auth_enabled=False,
|
||||
data_dir=Path(tmp),
|
||||
)
|
||||
|
||||
@app.on_event("startup")
|
||||
def _announce() -> None:
|
||||
print(f"READY port={_PORT}", flush=True)
|
||||
|
||||
config = uvicorn.Config(app, host=_HOST, port=_PORT, log_level="warning")
|
||||
server = uvicorn.Server(config)
|
||||
with contextlib.suppress(KeyboardInterrupt):
|
||||
server.run()
|
||||
sys.exit(0)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,87 @@
|
||||
"""Production entrypoint (T-2.7 — P-3 backend closure).
|
||||
|
||||
``uvicorn researchx.api.main:app`` — the ONLY serving entry the
|
||||
container ever runs (T-4.1). Distinct from ``researchx.api.app`` (the
|
||||
test/dev factory, whose module-global serves fakes):
|
||||
|
||||
- ``auth_enabled=True`` is HARDWIRED — not a parameter here, never an
|
||||
env var, never False on any path that serves traffic (REQ-85).
|
||||
- ``cookie_secure=True`` (E-4 handoff): the cookie is set with Secure
|
||||
because the public surface sits behind the TLS-fronted proxy.
|
||||
- Run artifacts + auth stores + key store + settings live under
|
||||
``RESEARCHX_DATA_DIR`` (container default ``/var/lib/researchx``,
|
||||
D-107) — never cwd-dependent.
|
||||
- Secrets come from the runtime env file (``.ciagent/.env.secrets``
|
||||
by default, overridable via ``RESEARCHX_SECRETS_FILE``; the
|
||||
container passes its own env-file via ``--env-file``):
|
||||
``RESEARCHX_KEK`` (keys scope) enables the per-user key store,
|
||||
``OLLAMA_API_KEY`` the server tier, ``TAVILY_API_KEY`` keyed search.
|
||||
- The model adapter is ALWAYS the real OllamaModelAdapter, constructed
|
||||
per run by the ModelAdapterFactory — this module can never serve
|
||||
FakeModelAdapter (pinned by test_main_entrypoint).
|
||||
- Search: TavilySearchAdapter when TAVILY_API_KEY is present, else the
|
||||
documented keyless DuckDuckGo fallback (degraded mode — G-008).
|
||||
- Single uvicorn worker only (in-memory RunRegistry + session store).
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import FastAPI
|
||||
|
||||
from researchx.adapters.duckduckgo import resolve_search_adapter
|
||||
from researchx.adapters.model_factory import ModelAdapterFactory
|
||||
from researchx.adapters.secrets import EnvFileSecretsProvider
|
||||
from researchx.api.app import create_app
|
||||
from researchx.auth.key_store import KeyStore
|
||||
from researchx.auth.settings import SettingsStore
|
||||
from researchx.config.paths import data_root
|
||||
|
||||
_DEFAULT_SECRETS_PATHS = [
|
||||
Path("/etc/researchx/env.secrets"), # container convention (runtime bind)
|
||||
Path.cwd() / ".ciagent" / ".env.secrets", # repo-local dev
|
||||
Path.cwd().parents[1] / ".ciagent" / ".env.secrets", # tests run from backend/
|
||||
]
|
||||
|
||||
|
||||
def _secrets_paths() -> list[Path]:
|
||||
"""Runtime env-file candidates (first existing wins per merge order)."""
|
||||
import os
|
||||
|
||||
override = os.environ.get("RESEARCHX_SECRETS_FILE")
|
||||
if override:
|
||||
return [Path(override)]
|
||||
return [p for p in _DEFAULT_SECRETS_PATHS if p.is_file()] or _DEFAULT_SECRETS_PATHS
|
||||
|
||||
|
||||
def build_app() -> FastAPI:
|
||||
"""Build the LIVE app: real adapters, real auth, data-root storage.
|
||||
|
||||
``auth_enabled`` and ``cookie_secure`` are not parameters — they
|
||||
are the production posture, fixed here (C3-16/REQ-85).
|
||||
"""
|
||||
secrets = EnvFileSecretsProvider(paths=_secrets_paths())
|
||||
root = data_root()
|
||||
|
||||
key_store = KeyStore(secrets, root)
|
||||
settings_store = SettingsStore(root)
|
||||
factory = ModelAdapterFactory(secrets, key_store)
|
||||
|
||||
# Tavily when keyed, documented keyless DuckDuckGo otherwise (G-008).
|
||||
search, _search_name = resolve_search_adapter(secrets)
|
||||
|
||||
return create_app(
|
||||
storage_dir=root,
|
||||
data_dir=root,
|
||||
search_adapter=search,
|
||||
secrets_provider=secrets,
|
||||
key_store=key_store,
|
||||
settings_store=settings_store,
|
||||
model_factory=factory,
|
||||
auth_enabled=True, # HARDWIRED — the parameter exists for the test factory only
|
||||
cookie_secure=True, # behind the TLS-front proxy (E-4)
|
||||
)
|
||||
|
||||
|
||||
# Module-level app for `uvicorn researchx.api.main:app`. This is the
|
||||
# production object; it is built from live wiring (never fakes).
|
||||
app = build_app()
|
||||
@@ -0,0 +1,61 @@
|
||||
"""Startup sweep (D-115, G-101).
|
||||
|
||||
On app construction, every non-terminal ``state.json`` under the
|
||||
storage base is rewritten as CANCELLED — in-flight actors died with the
|
||||
previous process; rehydration is out of v0.3 scope. CANCELLED is legal
|
||||
from every non-terminal state (states.py — including PLANNING per
|
||||
G-101, pinned by test). The registry-side pruning lives in app.py
|
||||
(RunRegistry.prune, P-6).
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from researchx.engine.run_state import RunState
|
||||
from researchx.engine.states import TERMINAL_STATES, RunStateName
|
||||
|
||||
|
||||
def _atomic_write(path: Path, content: str) -> None:
|
||||
import os
|
||||
import secrets
|
||||
|
||||
tmp_path = path.with_name(f"{path.name}.{secrets.token_hex(4)}.tmp")
|
||||
with open(tmp_path, "w", encoding="utf-8") as handle:
|
||||
handle.write(content)
|
||||
handle.flush()
|
||||
os.fsync(handle.fileno())
|
||||
tmp_path.replace(path)
|
||||
|
||||
|
||||
def sweep_in_flight_runs(base_dir: Path) -> list[tuple[str, str]]:
|
||||
"""Rewrite every non-terminal state.json under base_dir as CANCELLED.
|
||||
|
||||
Scans ``base_dir/runs/<owner_id>/<run_id>/state.json`` (the
|
||||
partitioned layout, D-106). Returns the swept (owner_id, run_id)
|
||||
pairs. Corrupt state files are skipped defensively — a sweep must
|
||||
never prevent startup.
|
||||
"""
|
||||
swept: list[tuple[str, str]] = []
|
||||
runs_dir = base_dir / "runs"
|
||||
if not runs_dir.is_dir():
|
||||
return swept
|
||||
for owner_dir in sorted(runs_dir.iterdir()):
|
||||
if not owner_dir.is_dir() or owner_dir.name.startswith("."):
|
||||
continue
|
||||
for run_dir in sorted(owner_dir.iterdir()):
|
||||
if not run_dir.is_dir() or run_dir.name.startswith("."):
|
||||
continue
|
||||
state_path = run_dir / "state.json"
|
||||
if not state_path.is_file():
|
||||
continue
|
||||
try:
|
||||
state = RunState.model_validate_json(state_path.read_text(encoding="utf-8"))
|
||||
except (ValueError, OSError):
|
||||
# Corrupt/unreadable state files are not swept, never
|
||||
# fatal — a sweep must never prevent startup.
|
||||
continue
|
||||
if state.state in TERMINAL_STATES:
|
||||
continue
|
||||
state.state = RunStateName.CANCELLED # legal from every non-terminal state (G-101)
|
||||
_atomic_write(state_path, state.model_dump_json())
|
||||
swept.append((owner_dir.name, run_dir.name))
|
||||
return swept
|
||||
@@ -0,0 +1,24 @@
|
||||
"""Auth package: user accounts, sessions, guard, and routes (REQ-69/70/71)."""
|
||||
|
||||
from researchx.auth.models import (
|
||||
AdminCreateUserBody,
|
||||
LoginBody,
|
||||
RegisterBody,
|
||||
UserPublic,
|
||||
to_public,
|
||||
)
|
||||
from researchx.auth.sessions import Session, SessionStore
|
||||
from researchx.auth.users import User, UserError, UserStore
|
||||
|
||||
__all__ = [
|
||||
"AdminCreateUserBody",
|
||||
"LoginBody",
|
||||
"RegisterBody",
|
||||
"Session",
|
||||
"SessionStore",
|
||||
"User",
|
||||
"UserError",
|
||||
"UserPublic",
|
||||
"UserStore",
|
||||
"to_public",
|
||||
]
|
||||
@@ -0,0 +1,56 @@
|
||||
"""Admin user-management routes (T-1.5, REQ-70).
|
||||
|
||||
Mounted on the PROTECTED router (session + CSRF already enforced by
|
||||
``require_user``); every route additionally requires the admin role.
|
||||
Responses expose ``UserPublic`` only — never a password hash. Account
|
||||
creation enforces the same >=12 password policy as registration;
|
||||
deactivation sets ``active=False`` and immediately revokes every
|
||||
session of the target user (data is retained).
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
|
||||
from researchx.auth.guard import Guard
|
||||
from researchx.auth.models import AdminCreateUserBody, UserPublic, to_public
|
||||
from researchx.auth.sessions import SessionStore
|
||||
from researchx.auth.users import UserError, UserStore
|
||||
|
||||
|
||||
def build_admin_router(
|
||||
users: UserStore,
|
||||
sessions: SessionStore,
|
||||
admin_guard: Guard,
|
||||
) -> APIRouter:
|
||||
"""Build the admin user surface (mounted at /api/admin, admin-only)."""
|
||||
router = APIRouter(
|
||||
prefix="/api/admin",
|
||||
tags=["admin"],
|
||||
dependencies=[Depends(admin_guard)],
|
||||
)
|
||||
|
||||
@router.get("/users", response_model=list[UserPublic])
|
||||
async def list_users() -> list[UserPublic]:
|
||||
return [to_public(u) for u in users.list()]
|
||||
|
||||
@router.post("/users", status_code=201, response_model=UserPublic)
|
||||
async def create_user(body: AdminCreateUserBody) -> UserPublic:
|
||||
try:
|
||||
user = await users.create_user(
|
||||
email=body.email,
|
||||
password=body.password,
|
||||
display_name=body.display_name or body.email.split("@")[0],
|
||||
role=body.role,
|
||||
)
|
||||
except UserError as exc:
|
||||
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
||||
return to_public(user)
|
||||
|
||||
@router.post("/users/{user_id}/deactivate", response_model=UserPublic)
|
||||
async def deactivate_user(user_id: str) -> UserPublic:
|
||||
updated = users.deactivate(user_id)
|
||||
if updated is None:
|
||||
raise HTTPException(status_code=404, detail="user not found")
|
||||
sessions.revoke_all_for(user_id) # deactivation kills sessions immediately
|
||||
return to_public(updated)
|
||||
|
||||
return router
|
||||
@@ -0,0 +1,109 @@
|
||||
"""Auth guard — session + CSRF enforcement as route dependencies (T-1.4).
|
||||
|
||||
Deliberately NOT middleware: router dependencies appear per-route, are
|
||||
overridable via ``app.dependency_overrides``, and never wrap SSE
|
||||
response bodies (no BaseHTTPMiddleware near the event stream).
|
||||
|
||||
Enforcement order:
|
||||
1. missing/invalid/expired session -> 401 "authentication required"
|
||||
2. non-GET without the CSRF header -> 403 "missing request header"
|
||||
3. missing or deactivated user -> 401 "account disabled"
|
||||
then the sliding-expiry ``touch()``.
|
||||
|
||||
When auth is disabled (dev/test harness only — a create_app parameter,
|
||||
never an env var readable in production, C3-16), the dev guard
|
||||
resolves a synthetic admin user for every request so all routes behave
|
||||
exactly as authenticated.
|
||||
"""
|
||||
|
||||
from collections.abc import Awaitable, Callable
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from fastapi import HTTPException, Request
|
||||
|
||||
from researchx.auth.models import UserPublic, to_public
|
||||
from researchx.auth.sessions import Session, SessionStore
|
||||
from researchx.auth.users import User, UserStore
|
||||
|
||||
SESSION_COOKIE = "researchx_session"
|
||||
CSRF_HEADER = "x-researchx-request"
|
||||
|
||||
DEV_USER_ID = "dev00000"
|
||||
|
||||
Guard = Callable[[Request], Awaitable[UserPublic]]
|
||||
|
||||
|
||||
def _dev_user() -> User:
|
||||
"""The synthetic admin every request resolves when auth is disabled."""
|
||||
return User(
|
||||
user_id=DEV_USER_ID,
|
||||
email="dev@localhost",
|
||||
password_hash="", # never persisted, never verified
|
||||
display_name="Dev User",
|
||||
role="admin",
|
||||
active=True,
|
||||
created_at=datetime.now(UTC),
|
||||
)
|
||||
|
||||
|
||||
def require_csrf_header(request: Request) -> None:
|
||||
"""CSRF defense for public POSTs (login/register) — same rule as the guard."""
|
||||
if request.method != "GET" and CSRF_HEADER not in request.headers:
|
||||
raise HTTPException(status_code=403, detail="missing request header")
|
||||
|
||||
|
||||
def build_guard(users: UserStore, sessions: SessionStore) -> Guard:
|
||||
"""Build the authenticated-mode require_user dependency."""
|
||||
|
||||
async def require_user(request: Request) -> UserPublic:
|
||||
token = request.cookies.get(SESSION_COOKIE)
|
||||
session: Session | None = sessions.get(token) if token else None
|
||||
if session is None:
|
||||
raise HTTPException(status_code=401, detail="authentication required")
|
||||
if request.method != "GET" and CSRF_HEADER not in request.headers:
|
||||
raise HTTPException(status_code=403, detail="missing request header")
|
||||
user: User | None = users.get(session.user_id)
|
||||
if user is None or not user.active:
|
||||
raise HTTPException(status_code=401, detail="account disabled")
|
||||
sessions.touch(session) # sliding expiry
|
||||
return to_public(user)
|
||||
|
||||
return require_user
|
||||
|
||||
|
||||
def build_dev_guard() -> Guard:
|
||||
"""Build the dev-mode guard: every request resolves the synthetic dev user."""
|
||||
|
||||
async def require_user(request: Request) -> UserPublic:
|
||||
del request # dev mode: no session, no CSRF — routes behave as before
|
||||
return to_public(_dev_user())
|
||||
|
||||
return require_user
|
||||
|
||||
|
||||
def build_admin_guard(guard: Guard) -> Guard:
|
||||
"""Wrap a guard with the admin role check (REQ-70).
|
||||
|
||||
The returned dependency resolves the wrapped guard first, so
|
||||
``app.dependency_overrides`` on either composes correctly.
|
||||
"""
|
||||
|
||||
async def require_admin(request: Request) -> UserPublic:
|
||||
user = await guard(request)
|
||||
if user.role != "admin":
|
||||
raise HTTPException(status_code=403, detail="admin role required")
|
||||
return user
|
||||
|
||||
return require_admin
|
||||
|
||||
|
||||
__all__ = [
|
||||
"CSRF_HEADER",
|
||||
"DEV_USER_ID",
|
||||
"SESSION_COOKIE",
|
||||
"Guard",
|
||||
"build_admin_guard",
|
||||
"build_dev_guard",
|
||||
"build_guard",
|
||||
"require_csrf_header",
|
||||
]
|
||||
@@ -0,0 +1,336 @@
|
||||
"""Per-user encrypted key store (T-2.2, REQ-73 — D-108/D-109).
|
||||
|
||||
AES-256-GCM envelope encryption of per-user model keys:
|
||||
|
||||
- KEK: ``RESEARCHX_KEK`` from the env-file secrets **'keys' scope**,
|
||||
accepted as urlsafe-base64 or hex, exactly 32 bytes (AES-256). Decode
|
||||
or length errors raise typed ``AdapterError`` (never a silent
|
||||
fallback). With no KEK configured the store is DISABLED — honestly,
|
||||
with a reason string — and the credential chain degrades to
|
||||
server/keyless (C3-06).
|
||||
- AAD: ``user_id.encode()`` — a key file copied under another user_id
|
||||
fails ``InvalidTag`` at decrypt time (cross-user binding by
|
||||
construction).
|
||||
- Nonce: fresh ``os.urandom(12)`` per write; a GCM nonce must NEVER
|
||||
repeat under the same KEK.
|
||||
- File: ``keys/<user_id>.json`` under the data root — ``{v, alg,
|
||||
updated_at, entries}`` — written atomically (tmp + ``os.replace``)
|
||||
with mode 0600.
|
||||
- Crypto runs in ``asyncio.to_thread`` so the event loop is never
|
||||
blocked by file I/O.
|
||||
- Scope: ``OLLAMA_API_KEY`` only in v0.3 (D-109).
|
||||
|
||||
KEK-loss posture (G-105, binding):
|
||||
The KEK is NOT recoverable from anything in this repository or in
|
||||
the running process. If the owner loses it, every per-user
|
||||
encrypted key becomes unrecoverable — by design, that is the point
|
||||
of envelope encryption. The honest degradation is: the key store
|
||||
reports disabled, the resolution chain falls to server/keyless,
|
||||
users re-enter their keys under a re-provisioned KEK, and the owner
|
||||
MUST back up the KEK off-repo. Rotation tooling is out of v0.3
|
||||
scope (recorded). A changed KEK requires a process restart — the
|
||||
secrets provider loads once at construction.
|
||||
|
||||
Corruption is never a silent tier drop: ``InvalidTag`` (AAD tamper,
|
||||
wrong KEK, garbage ciphertext) and malformed envelopes both surface as
|
||||
typed errors (``KeyStoreError``), so the credential chain can report an
|
||||
honest error state instead of quietly falling back.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
import os
|
||||
import secrets
|
||||
import threading
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
|
||||
from cryptography.exceptions import InvalidTag
|
||||
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
|
||||
from pydantic import BaseModel, Field, ValidationError
|
||||
|
||||
from researchx.adapters.secrets import SecretsProvider
|
||||
from researchx.adapters.types import AdapterError
|
||||
|
||||
KEK_NAME = "RESEARCHX_KEK"
|
||||
KEY_SCOPE = "OLLAMA_API_KEY"
|
||||
_NONCE_BYTES = 12
|
||||
_KEY_BYTES = 32
|
||||
|
||||
|
||||
class KeyStoreError(AdapterError):
|
||||
"""Typed key-store failure: corrupt/tampered envelope, bad KEK shape.
|
||||
|
||||
``kind`` lets callers (routes, factory, tests) distinguish the
|
||||
failure mode without parsing messages:
|
||||
|
||||
- ``disabled`` — no usable KEK; the store refuses honestly.
|
||||
- ``unreadable`` — the on-disk envelope is corrupt garbage.
|
||||
- ``tampered`` — decrypt failed (InvalidTag: wrong user AAD,
|
||||
wrong KEK, or bit-rot ciphertext). NEVER a
|
||||
silent None — the tier must not silently drop.
|
||||
- ``bad_kek`` — the configured KEK is malformed (decode/length).
|
||||
"""
|
||||
|
||||
def __init__(self, kind: str, message: str) -> None:
|
||||
super().__init__(message)
|
||||
self.kind = kind
|
||||
|
||||
|
||||
class _KeyEntry(BaseModel):
|
||||
nonce: str
|
||||
ct: str
|
||||
updated_at: str
|
||||
|
||||
|
||||
class _KeyEnvelope(BaseModel):
|
||||
v: int
|
||||
alg: str = "AES-256-GCM"
|
||||
updated_at: str
|
||||
entries: dict[str, _KeyEntry] = Field(default_factory=dict)
|
||||
|
||||
|
||||
def _now() -> str:
|
||||
return datetime.now(UTC).isoformat()
|
||||
|
||||
|
||||
def load_kek_bytes(secrets: SecretsProvider) -> bytes | None:
|
||||
"""Resolve ``RESEARCHX_KEK`` to raw 32 key bytes.
|
||||
|
||||
Accepted encodings: urlsafe-base64 (with or without padding) or
|
||||
hex. Anything that decodes but does not yield exactly 32 bytes, or
|
||||
fails to decode at all, raises ``KeyStoreError(kind="bad_kek")``
|
||||
(the research-file's typed AdapterError contract). ``None`` is
|
||||
returned only when the KEK is simply absent — the honest disabled
|
||||
state, distinct from a broken one.
|
||||
"""
|
||||
raw = secrets.resolve(KEK_NAME)
|
||||
if not raw:
|
||||
return None
|
||||
raw = raw.strip()
|
||||
# Try hex first (unambiguous: hex keys are [0-9a-fA-F]{64}); then
|
||||
# urlsafe b64 with flexible padding.
|
||||
try:
|
||||
if len(raw) == _KEY_BYTES * 2 and all(c in "0123456789abcdefABCDEF" for c in raw):
|
||||
return bytes.fromhex(raw)
|
||||
except ValueError as exc: # pragma: no cover — fromhex on checked chars can't fail
|
||||
raise KeyStoreError("bad_kek", f"{KEK_NAME} is not valid hex") from exc
|
||||
padded = raw + "=" * (-len(raw) % 4)
|
||||
try:
|
||||
decoded = base64.urlsafe_b64decode(padded.encode("ascii"))
|
||||
except (ValueError, TypeError) as exc:
|
||||
raise KeyStoreError(
|
||||
"bad_kek", f"{KEK_NAME} is not valid urlsafe-b64 or hex"
|
||||
) from exc
|
||||
if len(decoded) != _KEY_BYTES:
|
||||
raise KeyStoreError(
|
||||
"bad_kek", f"{KEK_NAME} must decode to exactly 32 bytes (AES-256), got {len(decoded)}"
|
||||
)
|
||||
return decoded
|
||||
|
||||
|
||||
def _encode(raw: bytes) -> str:
|
||||
return base64.urlsafe_b64encode(raw).decode("ascii").rstrip("=")
|
||||
|
||||
|
||||
def _decode(token: str) -> bytes:
|
||||
return base64.urlsafe_b64decode(token + "=" * (-len(token) % 4))
|
||||
|
||||
|
||||
class KeyStore:
|
||||
"""Per-user encrypted key store (``keys/<user_id>.json``).
|
||||
|
||||
The store is ENABLED iff a valid KEK resolves at construction.
|
||||
Every public method is async; file/crypto work runs in
|
||||
``asyncio.to_thread``.
|
||||
"""
|
||||
|
||||
def __init__(self, secrets: SecretsProvider, data_root: Path) -> None:
|
||||
self._secrets = secrets
|
||||
self._root = data_root / "keys"
|
||||
self._lock = threading.Lock()
|
||||
self._kek: bytes | None
|
||||
self._disabled_reason: str | None = None
|
||||
try:
|
||||
self._kek = load_kek_bytes(secrets)
|
||||
except KeyStoreError as exc:
|
||||
# A malformed KEK is a hard misconfiguration: treat the
|
||||
# store as disabled with the honest reason, never a crash
|
||||
# and never a silent enable with the wrong key.
|
||||
self._kek = None
|
||||
self._disabled_reason = f"{KEK_NAME} is misconfigured: {exc}"
|
||||
if self._kek is None and self._disabled_reason is None:
|
||||
self._disabled_reason = (
|
||||
f"no {KEK_NAME} configured (env-file secrets 'keys' scope); "
|
||||
"per-user model keys cannot be stored — the credential chain "
|
||||
"falls to the server tier or keyless localhost"
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------ state
|
||||
|
||||
@property
|
||||
def enabled(self) -> bool:
|
||||
return self._kek is not None
|
||||
|
||||
@property
|
||||
def disabled_reason(self) -> str | None:
|
||||
return self._disabled_reason
|
||||
|
||||
def _require_kek(self) -> bytes:
|
||||
assert self._kek is not None # internal invariant; callers gate on enabled
|
||||
return self._kek
|
||||
|
||||
def _path(self, user_id: str) -> Path:
|
||||
return self._root / f"{user_id}.json"
|
||||
|
||||
# ------------------------------------------------------- sync workers
|
||||
|
||||
def _read_envelope(self, user_id: str) -> _KeyEnvelope | None:
|
||||
path = self._path(user_id)
|
||||
try:
|
||||
raw = path.read_text(encoding="utf-8")
|
||||
except FileNotFoundError:
|
||||
return None
|
||||
try:
|
||||
return _KeyEnvelope.model_validate_json(raw)
|
||||
except ValidationError as exc:
|
||||
raise KeyStoreError(
|
||||
"unreadable",
|
||||
f"key envelope for user {user_id} is corrupt ({exc.error_count()} errors)",
|
||||
) from exc
|
||||
|
||||
def _set_key_locked(self, user_id: str, raw_key: str) -> None:
|
||||
kek = self._require_kek()
|
||||
nonce = os.urandom(_NONCE_BYTES) # fresh per write — NEVER reuse under the KEK
|
||||
ct = AESGCM(kek).encrypt(nonce, raw_key.encode("utf-8"), user_id.encode("utf-8"))
|
||||
envelope = _KeyEnvelope(
|
||||
v=1,
|
||||
alg="AES-256-GCM",
|
||||
updated_at=_now(),
|
||||
entries={KEY_SCOPE: _KeyEntry(nonce=_encode(nonce), ct=_encode(ct), updated_at=_now())},
|
||||
)
|
||||
path = self._path(user_id)
|
||||
self._root.mkdir(parents=True, exist_ok=True)
|
||||
tmp_path = path.with_name(f"{path.name}.{secrets.token_hex(4)}.tmp")
|
||||
with open(tmp_path, "w", encoding="utf-8") as handle:
|
||||
handle.write(envelope.model_dump_json(indent=2))
|
||||
handle.flush()
|
||||
os.fsync(handle.fileno())
|
||||
os.chmod(tmp_path, 0o600)
|
||||
os.replace(tmp_path, path) # atomic — a crash never leaves a torn file
|
||||
|
||||
def _get_key_locked(self, user_id: str) -> str | None:
|
||||
envelope = self._read_envelope(user_id)
|
||||
if envelope is None:
|
||||
return None
|
||||
entry = envelope.entries.get(KEY_SCOPE)
|
||||
if entry is None:
|
||||
return None
|
||||
kek = self._require_kek()
|
||||
try:
|
||||
plain = AESGCM(kek).decrypt(
|
||||
_decode(entry.nonce), _decode(entry.ct), user_id.encode("utf-8")
|
||||
)
|
||||
except InvalidTag as exc:
|
||||
# AAD binding (user_id) failed, the KEK changed, or the
|
||||
# ciphertext bit-rotted. This is a typed error, NEVER a
|
||||
# silent None — a silent drop would let the tier fall to
|
||||
# server/keyless without the user knowing their key is
|
||||
# unreadable (C3-06).
|
||||
raise KeyStoreError(
|
||||
"tampered",
|
||||
f"key envelope for user {user_id} failed authentication "
|
||||
"(wrong user binding, changed KEK, or tampering)",
|
||||
) from exc
|
||||
except (ValueError, TypeError) as exc:
|
||||
raise KeyStoreError(
|
||||
"unreadable", f"key envelope for user {user_id} has invalid encoding"
|
||||
) from exc
|
||||
return plain.decode("utf-8")
|
||||
|
||||
# ------------------------------------------------------------ API
|
||||
|
||||
async def set_key(self, user_id: str, scope: str, raw_key: str) -> None:
|
||||
"""Encrypt and persist *raw_key* for *user_id* under *scope*.
|
||||
|
||||
Rejected with a typed error when the store is disabled — an
|
||||
honest refusal, never a silent no-op.
|
||||
"""
|
||||
if scope != KEY_SCOPE:
|
||||
raise KeyStoreError("disabled", f"unsupported key scope: {scope} (v0.3: {KEY_SCOPE} only)")
|
||||
if not self.enabled:
|
||||
raise KeyStoreError("disabled", self._disabled_reason or "key store disabled")
|
||||
|
||||
def _work() -> None:
|
||||
with self._lock:
|
||||
self._set_key_locked(user_id, raw_key)
|
||||
|
||||
await asyncio.to_thread(_work)
|
||||
|
||||
async def get_key(self, user_id: str) -> str | None:
|
||||
"""Decrypt and return the user's key, or None when none stored.
|
||||
|
||||
Raises ``KeyStoreError`` on tamper/corruption (never a silent
|
||||
None), and when the store is disabled with a stored envelope
|
||||
that cannot be read.
|
||||
"""
|
||||
if not self.enabled:
|
||||
# Without a KEK nothing can be read — but an envelope that
|
||||
# EXISTS is surfaced as an error state (the user has a key
|
||||
# we cannot serve), not silently None.
|
||||
def _peek() -> bool:
|
||||
return self._path(user_id).is_file()
|
||||
|
||||
if await asyncio.to_thread(_peek):
|
||||
raise KeyStoreError(
|
||||
"disabled",
|
||||
f"a key is stored for user {user_id} but the key store is disabled ({self._disabled_reason})",
|
||||
)
|
||||
return None
|
||||
|
||||
def _work() -> str | None:
|
||||
with self._lock:
|
||||
return self._get_key_locked(user_id)
|
||||
|
||||
return await asyncio.to_thread(_work)
|
||||
|
||||
async def masked_status(self, user_id: str) -> dict[str, object]:
|
||||
"""Masked-only status: ``{configured, masked}`` — NEVER the raw key.
|
||||
|
||||
When the store is disabled the status says so honestly
|
||||
(``disabled: True`` + reason). A tampered/unreadable envelope
|
||||
is surfaced as an error field rather than ``configured: False``
|
||||
— never a silent tier drop.
|
||||
"""
|
||||
status: dict[str, object] = {"configured": False, "masked": None}
|
||||
if not self.enabled:
|
||||
status["disabled"] = True
|
||||
status["reason"] = self._disabled_reason
|
||||
|
||||
def _peek() -> bool:
|
||||
return self._path(user_id).is_file()
|
||||
|
||||
if await asyncio.to_thread(_peek):
|
||||
# An unreadable stored key is an error state, not "not configured".
|
||||
raise KeyStoreError(
|
||||
"disabled",
|
||||
f"a key is stored for user {user_id} but the key store is disabled ({self._disabled_reason})",
|
||||
)
|
||||
return status
|
||||
|
||||
def _work() -> dict[str, object]:
|
||||
with self._lock:
|
||||
envelope = self._read_envelope(user_id)
|
||||
if envelope is None or envelope.entries.get(KEY_SCOPE) is None:
|
||||
return {"configured": False, "masked": None, "disabled": False}
|
||||
key = self._get_key_locked(user_id) # surfaces tamper as KeyStoreError
|
||||
return {
|
||||
"configured": True,
|
||||
"masked": f"…{key[-4:]}" if key and len(key) >= 4 else "…",
|
||||
"disabled": False,
|
||||
}
|
||||
|
||||
return await asyncio.to_thread(_work)
|
||||
|
||||
|
||||
__all__ = ["KEK_NAME", "KEY_SCOPE", "KeyStore", "KeyStoreError", "load_kek_bytes"]
|
||||
@@ -0,0 +1,60 @@
|
||||
"""Auth API request/response contracts (T-1.3).
|
||||
|
||||
These are the wire models for the register/login/logout/me routes and
|
||||
the admin user surface. Password hashes never appear in any of them
|
||||
(REQ-69: no password-hash material in any API response).
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
from researchx.auth.users import MIN_PASSWORD_LENGTH, User
|
||||
|
||||
# Lightweight email shape check — the auth surface needs a stable,
|
||||
# normalizable identifier, not a full RFC 5322 validation (and pulling
|
||||
# pydantic[email] would add an unplanned dependency).
|
||||
_EMAIL_RE = r"^[^\s@]+@[^\s@]+\.[^\s@]+$"
|
||||
|
||||
|
||||
class RegisterBody(BaseModel):
|
||||
email: str = Field(pattern=_EMAIL_RE, max_length=254)
|
||||
password: str = Field(min_length=MIN_PASSWORD_LENGTH)
|
||||
display_name: str = Field(default="", max_length=120)
|
||||
|
||||
|
||||
class LoginBody(BaseModel):
|
||||
email: str = Field(pattern=_EMAIL_RE, max_length=254)
|
||||
password: str = Field(min_length=1)
|
||||
|
||||
|
||||
class AdminCreateUserBody(BaseModel):
|
||||
email: str = Field(pattern=_EMAIL_RE, max_length=254)
|
||||
password: str = Field(min_length=MIN_PASSWORD_LENGTH)
|
||||
display_name: str = Field(default="", max_length=120)
|
||||
role: str = Field(default="user", pattern=r"^(admin|user)$")
|
||||
|
||||
|
||||
class UserPublic(BaseModel):
|
||||
"""A user as exposed on the wire — no password_hash, ever."""
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
user_id: str
|
||||
email: str
|
||||
display_name: str
|
||||
role: str
|
||||
active: bool
|
||||
created_at: datetime
|
||||
|
||||
|
||||
def to_public(user: User) -> UserPublic:
|
||||
"""Project a stored ``User`` to its public wire shape."""
|
||||
return UserPublic(
|
||||
user_id=user.user_id,
|
||||
email=user.email,
|
||||
display_name=user.display_name,
|
||||
role=user.role,
|
||||
active=user.active,
|
||||
created_at=user.created_at,
|
||||
)
|
||||
@@ -0,0 +1,119 @@
|
||||
"""Auth routes: register / login / logout / me (T-1.4, REQ-69/70).
|
||||
|
||||
Mounted on a PUBLIC router at /api/auth/* (no require_user dependency)
|
||||
— but login/register POSTs still enforce the CSRF header (C3-05) via
|
||||
``Depends(require_csrf_header)``.
|
||||
|
||||
Route semantics:
|
||||
- register: open only while the user store is empty; the first user
|
||||
becomes admin, everything else 403s. The only await between the
|
||||
emptiness check and the insert is the argon2 hash; re-checking under
|
||||
the store lock keeps it race-free (D-104, single worker by design).
|
||||
- login: uniform 401 "invalid email or password" for every failure
|
||||
mode (no user enumeration); a fresh token is issued every login
|
||||
(session-fixation defense by rotation); the httpOnly cookie is set
|
||||
with SameSite=Lax, Path=/, no Domain attribute, Secure when
|
||||
configured for TLS-fronted hosts.
|
||||
- logout: revokes the presented token immediately.
|
||||
- me: the current user (session only — no CSRF header on GETs).
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, Response
|
||||
|
||||
from researchx.auth.guard import SESSION_COOKIE, require_csrf_header
|
||||
from researchx.auth.models import LoginBody, RegisterBody, UserPublic, to_public
|
||||
from researchx.auth.sessions import SessionStore
|
||||
from researchx.auth.users import UserError, UserStore
|
||||
|
||||
_COOKIE_MAX_AGE = 14 * 24 * 3600
|
||||
|
||||
|
||||
def build_auth_router(
|
||||
users: UserStore,
|
||||
sessions: SessionStore,
|
||||
cookie_secure: bool,
|
||||
) -> APIRouter:
|
||||
"""Build the public auth router (mounted at /api/auth)."""
|
||||
router = APIRouter(prefix="/api/auth", tags=["auth"])
|
||||
|
||||
def _set_session_cookie(response: Response, token: str) -> None:
|
||||
response.set_cookie(
|
||||
key=SESSION_COOKIE,
|
||||
value=token,
|
||||
max_age=_COOKIE_MAX_AGE,
|
||||
httponly=True,
|
||||
samesite="lax",
|
||||
path="/",
|
||||
secure=cookie_secure,
|
||||
)
|
||||
|
||||
def _presented_token(request: Request) -> str | None:
|
||||
return request.cookies.get(SESSION_COOKIE)
|
||||
|
||||
@router.post(
|
||||
"/register",
|
||||
status_code=201,
|
||||
response_model=UserPublic,
|
||||
dependencies=[Depends(require_csrf_header)],
|
||||
)
|
||||
async def register(body: RegisterBody) -> UserPublic:
|
||||
# Fast-path 403 for the common case; the authoritative
|
||||
# emptiness re-check happens under the store lock inside
|
||||
# create_first_admin (D-104) so a concurrent duplicate
|
||||
# register cannot both succeed.
|
||||
if not users.empty():
|
||||
raise HTTPException(status_code=403, detail="registration closed")
|
||||
try:
|
||||
user = await users.create_first_admin(
|
||||
email=body.email,
|
||||
password=body.password,
|
||||
display_name=body.display_name or body.email.split("@")[0],
|
||||
)
|
||||
except UserError as exc:
|
||||
raise HTTPException(status_code=403, detail=str(exc)) from exc
|
||||
return to_public(user)
|
||||
|
||||
@router.post(
|
||||
"/login",
|
||||
response_model=UserPublic,
|
||||
dependencies=[Depends(require_csrf_header)],
|
||||
)
|
||||
async def login(body: LoginBody, response: Response) -> UserPublic:
|
||||
user = users.get_by_email(body.email)
|
||||
if user is None or not await users.verify_password(user, body.password):
|
||||
raise HTTPException(status_code=401, detail="invalid email or password")
|
||||
if not user.active:
|
||||
raise HTTPException(status_code=401, detail="invalid email or password")
|
||||
# Token rotation: a fresh token on every login kills session
|
||||
# fixation by construction (D-101).
|
||||
token = sessions.generate_token()
|
||||
sessions.create(token, user.user_id)
|
||||
_set_session_cookie(response, token)
|
||||
if await users.needs_rehash(user):
|
||||
await users.rehash_password(user, body.password)
|
||||
return to_public(user)
|
||||
|
||||
@router.post(
|
||||
"/logout",
|
||||
dependencies=[Depends(require_csrf_header)],
|
||||
)
|
||||
async def logout(request: Request, response: Response) -> dict[str, bool]:
|
||||
token = _presented_token(request)
|
||||
if token:
|
||||
sessions.revoke(token)
|
||||
response.delete_cookie(key=SESSION_COOKIE, path="/")
|
||||
return {"ok": True}
|
||||
|
||||
@router.get("/me", response_model=UserPublic)
|
||||
async def me(request: Request) -> UserPublic:
|
||||
token = _presented_token(request)
|
||||
session = sessions.get(token) if token else None
|
||||
if session is None:
|
||||
raise HTTPException(status_code=401, detail="authentication required")
|
||||
user = users.get(session.user_id)
|
||||
if user is None or not user.active:
|
||||
raise HTTPException(status_code=401, detail="authentication required")
|
||||
sessions.touch(session)
|
||||
return to_public(user)
|
||||
|
||||
return router
|
||||
@@ -0,0 +1,161 @@
|
||||
"""Session store — server-side, opaque tokens, durable JSON mirror (D-101).
|
||||
|
||||
Tokens are ``secrets.token_urlsafe(32)``; only their sha256 hash is
|
||||
stored (in memory and in ``<data_root>/sessions.json``). Expiry is
|
||||
14-day sliding: every authenticated request calls ``touch()`` which
|
||||
pushes ``expires_at`` forward, persisting at most once per 60s.
|
||||
Revocation is immediate (logout deletes the presented token;
|
||||
deactivation deletes every token of the user).
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import secrets
|
||||
import threading
|
||||
import time
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from pathlib import Path
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, ValidationError
|
||||
|
||||
SESSION_TTL_SECONDS = 14 * 24 * 3600
|
||||
_PERSIST_THROTTLE_SECONDS = 60.0
|
||||
|
||||
|
||||
class Session(BaseModel):
|
||||
token_hash: str
|
||||
user_id: str
|
||||
created_at: datetime = Field(default_factory=lambda: datetime.now(UTC))
|
||||
last_seen: datetime = Field(default_factory=lambda: datetime.now(UTC))
|
||||
expires_at: datetime
|
||||
|
||||
model_config = ConfigDict(frozen=True)
|
||||
|
||||
|
||||
def _hash_token(token: str) -> str:
|
||||
return hashlib.sha256(token.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
class SessionStore:
|
||||
"""In-memory map keyed by token_hash + durable sessions.json mirror."""
|
||||
|
||||
def __init__(self, path: Path) -> None:
|
||||
self._path = path
|
||||
self._sessions: dict[str, Session] = {}
|
||||
self._lock = threading.Lock()
|
||||
self._last_persist = 0.0
|
||||
self._load()
|
||||
|
||||
# ------------------------------------------------------------ persistence
|
||||
|
||||
def _load(self) -> None:
|
||||
if not self._path.exists():
|
||||
return
|
||||
try:
|
||||
raw = json.loads(self._path.read_text(encoding="utf-8"))
|
||||
except json.JSONDecodeError:
|
||||
return
|
||||
for record in raw.get("sessions", []):
|
||||
try:
|
||||
session = Session.model_validate(record)
|
||||
except (ValidationError, KeyError, TypeError):
|
||||
continue # defensive: skip corrupt records on load
|
||||
self._sessions[session.token_hash] = session
|
||||
|
||||
def _persist_locked(self) -> None:
|
||||
self._path.parent.mkdir(parents=True, exist_ok=True)
|
||||
payload = {
|
||||
"sessions": [s.model_dump(mode="json") for s in self._sessions.values()]
|
||||
}
|
||||
tmp_path = self._path.with_name(f"{self._path.name}.{secrets.token_hex(4)}.tmp")
|
||||
content = json.dumps(payload, indent=2)
|
||||
with open(tmp_path, "w", encoding="utf-8") as handle:
|
||||
handle.write(content)
|
||||
handle.flush()
|
||||
os.fsync(handle.fileno())
|
||||
os.replace(tmp_path, self._path)
|
||||
self._last_persist = time.monotonic()
|
||||
|
||||
def _persist(self) -> None:
|
||||
with self._lock:
|
||||
self._persist_locked()
|
||||
|
||||
# ------------------------------------------------------------- queries
|
||||
|
||||
def get(self, token: str) -> Session | None:
|
||||
"""Resolve a presented token; expired or unknown tokens yield None."""
|
||||
token_hash = _hash_token(token)
|
||||
session = self._sessions.get(token_hash)
|
||||
if session is None:
|
||||
return None
|
||||
if session.expires_at <= datetime.now(UTC):
|
||||
with self._lock:
|
||||
expired = self._sessions.pop(token_hash, None)
|
||||
if expired is not None:
|
||||
self._persist_locked()
|
||||
return None
|
||||
return session
|
||||
|
||||
# ------------------------------------------------------------- mutation
|
||||
|
||||
def create(self, token: str, user_id: str) -> Session:
|
||||
"""Store a new session for *token* (only its hash is kept)."""
|
||||
now = datetime.now(UTC)
|
||||
session = Session(
|
||||
token_hash=_hash_token(token),
|
||||
user_id=user_id,
|
||||
created_at=now,
|
||||
last_seen=now,
|
||||
expires_at=now + timedelta(seconds=SESSION_TTL_SECONDS),
|
||||
)
|
||||
with self._lock:
|
||||
self._sessions[session.token_hash] = session
|
||||
self._persist_locked()
|
||||
return session
|
||||
|
||||
def touch(self, session: Session) -> None:
|
||||
"""Sliding expiry: push expires_at forward, throttled persist."""
|
||||
now = datetime.now(UTC)
|
||||
renewed = Session(
|
||||
token_hash=session.token_hash,
|
||||
user_id=session.user_id,
|
||||
created_at=session.created_at,
|
||||
last_seen=now,
|
||||
expires_at=now + timedelta(seconds=SESSION_TTL_SECONDS),
|
||||
)
|
||||
with self._lock:
|
||||
current = self._sessions.get(session.token_hash)
|
||||
if current is None or current != session:
|
||||
return # stale or raced touch — nothing to extend
|
||||
self._sessions[renewed.token_hash] = renewed
|
||||
if time.monotonic() - self._last_persist > _PERSIST_THROTTLE_SECONDS:
|
||||
self._persist_locked()
|
||||
|
||||
def revoke(self, token: str) -> None:
|
||||
"""Immediately delete the session for *token* (logout)."""
|
||||
token_hash = _hash_token(token)
|
||||
with self._lock:
|
||||
if self._sessions.pop(token_hash, None) is not None:
|
||||
self._persist_locked()
|
||||
|
||||
def revoke_all_for(self, user_id: str) -> None:
|
||||
"""Immediately delete every session of *user_id* (deactivation)."""
|
||||
with self._lock:
|
||||
doomed = [h for h, s in self._sessions.items() if s.user_id == user_id]
|
||||
for token_hash in doomed:
|
||||
del self._sessions[token_hash]
|
||||
if doomed:
|
||||
self._persist_locked()
|
||||
|
||||
def generate_token(self) -> str:
|
||||
return secrets.token_urlsafe(32)
|
||||
|
||||
# ----------------------------------------------------------- asyncio shim
|
||||
|
||||
async def arevoke(self, token: str) -> None:
|
||||
await asyncio.to_thread(self.revoke, token)
|
||||
|
||||
async def arevoke_all_for(self, user_id: str) -> None:
|
||||
await asyncio.to_thread(self.revoke_all_for, user_id)
|
||||
@@ -0,0 +1,104 @@
|
||||
"""Per-user settings persistence (T-2.3, REQ-72 — D-111).
|
||||
|
||||
``settings/<user_id>.json`` under the data root, written atomically
|
||||
(tmp + ``os.replace``). Fresh users get defaults on read (GET never
|
||||
writes). The ``UserSettings`` model carries the per-user surface the
|
||||
Settings page binds to: planner/writer model names, methodology
|
||||
defaults (resolved dial overrides per methodology — free-form dict in
|
||||
v0.3, validated at use time), and the appearance slot.
|
||||
|
||||
The storage directory is server-managed and display-only (D-111) — it
|
||||
is NOT part of this payload; the config status route surfaces it.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import secrets
|
||||
import threading
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel, Field, ValidationError
|
||||
|
||||
from researchx.adapters.types import AdapterError
|
||||
|
||||
# Default model names — the same defaults the live smoke tier uses
|
||||
# (gpt-oss:20b on ollama.com). These are fallbacks only: the frontend
|
||||
# Settings page always shows the current value.
|
||||
DEFAULT_PLANNER_MODEL = "gpt-oss:20b"
|
||||
DEFAULT_WRITER_MODEL = "gpt-oss:20b"
|
||||
|
||||
|
||||
class UserSettings(BaseModel):
|
||||
planner_model: str = DEFAULT_PLANNER_MODEL
|
||||
writer_model: str = DEFAULT_WRITER_MODEL
|
||||
methodology_defaults: dict[str, Any] = Field(default_factory=dict)
|
||||
appearance: str = "system"
|
||||
|
||||
|
||||
class SettingsStoreError(AdapterError):
|
||||
"""Typed settings-store failure (corrupt on-disk payload)."""
|
||||
|
||||
|
||||
class SettingsStore:
|
||||
"""Per-user settings, one JSON file per user, atomic writes."""
|
||||
|
||||
def __init__(self, data_root: Path) -> None:
|
||||
self._root = data_root / "settings"
|
||||
self._lock = threading.Lock()
|
||||
|
||||
def _path(self, user_id: str) -> Path:
|
||||
return self._root / f"{user_id}.json"
|
||||
|
||||
# ------------------------------------------------------------ sync
|
||||
|
||||
def _read_sync(self, user_id: str) -> UserSettings:
|
||||
path = self._path(user_id)
|
||||
try:
|
||||
raw = path.read_text(encoding="utf-8")
|
||||
except FileNotFoundError:
|
||||
return UserSettings() # fresh user: defaults, no write on read
|
||||
try:
|
||||
return UserSettings.model_validate_json(raw)
|
||||
except ValidationError as exc:
|
||||
raise SettingsStoreError(
|
||||
f"settings payload for user {user_id} is corrupt ({exc.error_count()} errors)"
|
||||
) from exc
|
||||
|
||||
def _update_sync(self, user_id: str, partial: UserSettings) -> UserSettings:
|
||||
current = self._read_sync(user_id)
|
||||
merged = current.model_copy(update=partial.model_dump(exclude_unset=True))
|
||||
# Validate the merged payload BEFORE it touches disk: a null
|
||||
# field slipped past model_copy (it skips validation) would
|
||||
# poison the file — every later read would raise corrupt.
|
||||
merged = UserSettings.model_validate(merged.model_dump())
|
||||
path = self._path(user_id)
|
||||
self._root.mkdir(parents=True, exist_ok=True)
|
||||
tmp_path = path.with_name(f"{path.name}.{secrets.token_hex(4)}.tmp")
|
||||
with open(tmp_path, "w", encoding="utf-8") as handle:
|
||||
handle.write(merged.model_dump_json(indent=2))
|
||||
handle.flush()
|
||||
os.fsync(handle.fileno())
|
||||
tmp_path.replace(path) # atomic
|
||||
return merged
|
||||
|
||||
# ------------------------------------------------------------ async
|
||||
|
||||
async def get(self, user_id: str) -> UserSettings:
|
||||
return await asyncio.to_thread(self._read_sync, user_id)
|
||||
|
||||
async def update(self, user_id: str, partial: UserSettings) -> UserSettings:
|
||||
def _work() -> UserSettings:
|
||||
with self._lock:
|
||||
return self._update_sync(user_id, partial)
|
||||
|
||||
return await asyncio.to_thread(_work)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"DEFAULT_PLANNER_MODEL",
|
||||
"DEFAULT_WRITER_MODEL",
|
||||
"SettingsStore",
|
||||
"SettingsStoreError",
|
||||
"UserSettings",
|
||||
]
|
||||
@@ -0,0 +1,203 @@
|
||||
"""User store — durable JSON-backed account registry (REQ-69, D-102/D-105).
|
||||
|
||||
Persisted to ``<data_root>/users.json`` with the atomic tmp+``os.replace``
|
||||
idiom (D-017). Password hashing is argon2id via argon2-cffi
|
||||
``PasswordHasher()`` defaults; every hash/verify runs through
|
||||
``asyncio.to_thread`` because argon2 verify at default parameters costs
|
||||
~50-100ms of CPU and must leave the event loop alone.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import secrets
|
||||
import threading
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
|
||||
from argon2 import PasswordHasher
|
||||
from argon2.exceptions import VerifyMismatchError
|
||||
from pydantic import BaseModel, ConfigDict, Field, ValidationError
|
||||
|
||||
from researchx.config.ids import generate_slug
|
||||
|
||||
MIN_PASSWORD_LENGTH = 12
|
||||
|
||||
|
||||
class UserError(ValueError):
|
||||
"""Raised for user-store policy violations (duplicate email, weak password)."""
|
||||
|
||||
|
||||
class User(BaseModel):
|
||||
user_id: str = Field(pattern=r"^[a-z0-9]{8}$")
|
||||
email: str
|
||||
password_hash: str
|
||||
display_name: str
|
||||
role: str = "user"
|
||||
active: bool = True
|
||||
created_at: datetime = Field(default_factory=lambda: datetime.now(UTC))
|
||||
|
||||
model_config = ConfigDict(frozen=True)
|
||||
|
||||
|
||||
class UserStore:
|
||||
"""In-memory map keyed by user_id + durable users.json mirror."""
|
||||
|
||||
def __init__(self, path: Path) -> None:
|
||||
self._path = path
|
||||
self._by_id: dict[str, User] = {}
|
||||
self._by_email: dict[str, User] = {}
|
||||
self._lock = threading.Lock()
|
||||
self._hasher = PasswordHasher()
|
||||
self._load()
|
||||
|
||||
# ------------------------------------------------------------ persistence
|
||||
|
||||
def _load(self) -> None:
|
||||
if not self._path.exists():
|
||||
return
|
||||
try:
|
||||
raw = json.loads(self._path.read_text(encoding="utf-8"))
|
||||
except json.JSONDecodeError as exc:
|
||||
# FAIL CLOSED on a corrupt user store. Swallowing it would
|
||||
# silently report an empty store, reopening registration on
|
||||
# top of existing accounts — the next persist would then
|
||||
# overwrite them (auth-reset-by-corruption). The file is
|
||||
# renamed aside so an operator can recover it; startup is
|
||||
# refused rather than silently degrading.
|
||||
salvage = self._path.with_name(f"{self._path.name}.corrupt.{secrets.token_hex(4)}")
|
||||
os.replace(self._path, salvage)
|
||||
raise RuntimeError(
|
||||
f"corrupt user store {self._path}; moved aside to {salvage}"
|
||||
) from exc
|
||||
for record in raw.get("users", []):
|
||||
try:
|
||||
user = User.model_validate(record)
|
||||
except (ValidationError, KeyError, TypeError):
|
||||
continue # defensive: skip corrupt records on load
|
||||
self._by_id[user.user_id] = user
|
||||
self._by_email[user.email] = user
|
||||
|
||||
def _persist_locked(self) -> None:
|
||||
self._path.parent.mkdir(parents=True, exist_ok=True)
|
||||
payload = {"users": [u.model_dump(mode="json") for u in self._by_id.values()]}
|
||||
tmp_path = self._path.with_name(f"{self._path.name}.{secrets.token_hex(4)}.tmp")
|
||||
content = json.dumps(payload, indent=2)
|
||||
with open(tmp_path, "w", encoding="utf-8") as handle:
|
||||
handle.write(content)
|
||||
handle.flush()
|
||||
os.fsync(handle.fileno())
|
||||
os.replace(tmp_path, self._path)
|
||||
|
||||
def _persist(self) -> None:
|
||||
with self._lock:
|
||||
self._persist_locked()
|
||||
|
||||
# ------------------------------------------------------------- hashing
|
||||
|
||||
async def hash_password(self, password: str) -> str:
|
||||
return await asyncio.to_thread(self._hasher.hash, password)
|
||||
|
||||
async def verify_password(self, user: User, password: str) -> bool:
|
||||
try:
|
||||
await asyncio.to_thread(self._hasher.verify, user.password_hash, password)
|
||||
except VerifyMismatchError:
|
||||
return False
|
||||
return True
|
||||
|
||||
async def needs_rehash(self, user: User) -> bool:
|
||||
return await asyncio.to_thread(self._hasher.check_needs_rehash, user.password_hash)
|
||||
|
||||
# ------------------------------------------------------------- queries
|
||||
|
||||
def empty(self) -> bool:
|
||||
return not self._by_id
|
||||
|
||||
def get(self, user_id: str) -> User | None:
|
||||
return self._by_id.get(user_id)
|
||||
|
||||
def get_by_email(self, email: str) -> User | None:
|
||||
return self._by_email.get(email.casefold())
|
||||
|
||||
def list(self) -> list[User]:
|
||||
return sorted(self._by_id.values(), key=lambda u: (u.created_at, u.user_id))
|
||||
|
||||
# ------------------------------------------------------------- mutation
|
||||
|
||||
async def create_user(
|
||||
self,
|
||||
*,
|
||||
email: str,
|
||||
password: str,
|
||||
display_name: str,
|
||||
role: str = "user",
|
||||
) -> User:
|
||||
normalized = email.casefold()
|
||||
if len(password) < MIN_PASSWORD_LENGTH:
|
||||
raise UserError(f"password must be at least {MIN_PASSWORD_LENGTH} characters")
|
||||
if self._by_email.get(normalized) is not None:
|
||||
raise UserError("email already registered")
|
||||
password_hash = await self.hash_password(password)
|
||||
user = User(
|
||||
user_id=generate_slug(),
|
||||
email=normalized,
|
||||
password_hash=password_hash,
|
||||
display_name=display_name,
|
||||
role=role,
|
||||
)
|
||||
with self._lock:
|
||||
if self._by_email.get(normalized) is not None:
|
||||
raise UserError("email already registered")
|
||||
self._by_id[user.user_id] = user
|
||||
self._by_email[user.email] = user
|
||||
self._persist_locked()
|
||||
return user
|
||||
|
||||
async def rehash_password(self, user: User, password: str) -> None:
|
||||
"""Rehash a user's password in place (login-time rehash on parameter drift)."""
|
||||
password_hash = await self.hash_password(password)
|
||||
with self._lock:
|
||||
updated = user.model_copy(update={"password_hash": password_hash})
|
||||
self._by_id[updated.user_id] = updated
|
||||
self._by_email[updated.email] = updated
|
||||
self._persist_locked()
|
||||
|
||||
async def create_first_admin(self, *, email: str, password: str, display_name: str) -> User:
|
||||
"""Atomically create the first user, who is admin by construction.
|
||||
|
||||
The emptiness check + insert both happen under the store lock;
|
||||
the only await (the argon2 hash) precedes the check. Combined
|
||||
with the single uvicorn worker this makes registration open
|
||||
exactly once (D-104).
|
||||
"""
|
||||
if len(password) < MIN_PASSWORD_LENGTH:
|
||||
raise UserError(f"password must be at least {MIN_PASSWORD_LENGTH} characters")
|
||||
normalized = email.casefold()
|
||||
password_hash = await self.hash_password(password) # the one allowed await
|
||||
with self._lock:
|
||||
if self._by_id:
|
||||
raise UserError("registration closed")
|
||||
if self._by_email.get(normalized) is not None:
|
||||
raise UserError("email already registered")
|
||||
user = User(
|
||||
user_id=generate_slug(),
|
||||
email=normalized,
|
||||
password_hash=password_hash,
|
||||
display_name=display_name,
|
||||
role="admin",
|
||||
)
|
||||
self._by_id[user.user_id] = user
|
||||
self._by_email[user.email] = user
|
||||
self._persist_locked()
|
||||
return user
|
||||
|
||||
def deactivate(self, user_id: str) -> User | None:
|
||||
with self._lock:
|
||||
user = self._by_id.get(user_id)
|
||||
if user is None:
|
||||
return None
|
||||
updated = user.model_copy(update={"active": False})
|
||||
self._by_id[user_id] = updated
|
||||
self._by_email[updated.email] = updated
|
||||
self._persist_locked()
|
||||
return updated
|
||||
@@ -0,0 +1,111 @@
|
||||
from types import MappingProxyType
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
SourceUniverse = Literal["web", "academic", "local", "web+academic"]
|
||||
SearchRouting = Literal["single", "adaptive", "academic-only"]
|
||||
QueryStrategy = Literal["fixed", "iterative", "decomposed"]
|
||||
QualityGates = Literal["none", "standard", "strict"]
|
||||
CitationRigor = Literal["per-claim", "per-section", "per-report"]
|
||||
SynthesisStyle = Literal["memo", "executive", "encyclopedic", "adversarial"]
|
||||
OutputStructure = Literal["brief", "report", "dossier"]
|
||||
MemoryScope = Literal["none", "project", "cross-project"]
|
||||
|
||||
|
||||
class DialDescriptor(BaseModel, frozen=True):
|
||||
"""UI display metadata for a single dial."""
|
||||
|
||||
name: str
|
||||
label: str
|
||||
description: str
|
||||
|
||||
|
||||
class Dials(BaseModel, frozen=True):
|
||||
"""The 12 methodology dials with safe defaults (REQ-01)."""
|
||||
|
||||
source_universe: SourceUniverse = "web"
|
||||
search_routing: SearchRouting = "single"
|
||||
query_strategy: QueryStrategy = "decomposed"
|
||||
breadth: int = Field(default=2, ge=1, le=5)
|
||||
depth: int = Field(default=1, ge=1, le=3)
|
||||
quality_gates: QualityGates = "standard"
|
||||
citation_rigor: CitationRigor = "per-claim"
|
||||
verification_passes: int = Field(default=1, ge=0, le=3)
|
||||
synthesis_style: SynthesisStyle = "memo"
|
||||
output_structure: OutputStructure = "brief"
|
||||
memory_scope: MemoryScope = "none"
|
||||
checkpoints: bool = False
|
||||
|
||||
|
||||
DIAL_REGISTRY: MappingProxyType[str, DialDescriptor] = MappingProxyType(
|
||||
{
|
||||
"source_universe": DialDescriptor(
|
||||
name="source_universe",
|
||||
label="Source universe",
|
||||
description="Which sources may be searched: web, academic, local, or both.",
|
||||
),
|
||||
"search_routing": DialDescriptor(
|
||||
name="search_routing",
|
||||
label="Search routing",
|
||||
description="How queries are routed: single endpoint, adaptive, or academic-only.",
|
||||
),
|
||||
"query_strategy": DialDescriptor(
|
||||
name="query_strategy",
|
||||
label="Query strategy",
|
||||
description="How the question becomes queries: fixed, iterative, or decomposed.",
|
||||
),
|
||||
"breadth": DialDescriptor(
|
||||
name="breadth",
|
||||
label="Breadth",
|
||||
description="Number of parallel sub-question searches (1-5).",
|
||||
),
|
||||
"depth": DialDescriptor(
|
||||
name="depth",
|
||||
label="Depth",
|
||||
description="Number of reflection and refinement loops (1-3).",
|
||||
),
|
||||
"quality_gates": DialDescriptor(
|
||||
name="quality_gates",
|
||||
label="Quality gates",
|
||||
description="How strictly sources are filtered before synthesis.",
|
||||
),
|
||||
"citation_rigor": DialDescriptor(
|
||||
name="citation_rigor",
|
||||
label="Citation rigor",
|
||||
description="Granularity of citations: per claim, per section, or per report.",
|
||||
),
|
||||
"verification_passes": DialDescriptor(
|
||||
name="verification_passes",
|
||||
label="Verification passes",
|
||||
description="Post-synthesis verification passes over claims (0-3).",
|
||||
),
|
||||
"synthesis_style": DialDescriptor(
|
||||
name="synthesis_style",
|
||||
label="Synthesis style",
|
||||
description="Voice of the synthesized report.",
|
||||
),
|
||||
"output_structure": DialDescriptor(
|
||||
name="output_structure",
|
||||
label="Output structure",
|
||||
description="Shape of the final artifact: brief, report, or dossier.",
|
||||
),
|
||||
"memory_scope": DialDescriptor(
|
||||
name="memory_scope",
|
||||
label="Memory scope",
|
||||
description="What prior context a run may draw on.",
|
||||
),
|
||||
"checkpoints": DialDescriptor(
|
||||
name="checkpoints",
|
||||
label="Checkpoints",
|
||||
description="Persist intermediate state so a run can pause and resume.",
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"DIAL_REGISTRY",
|
||||
"DialDescriptor",
|
||||
"Dials",
|
||||
]
|
||||
@@ -0,0 +1,25 @@
|
||||
"""8-char slug ids — the single generator + validation (P-14 consolidation).
|
||||
|
||||
Run ids and user ids share one alphabet and one shape: 8 lowercase
|
||||
alphanumeric characters, validated by ``^[a-z0-9]{8}$`` (D-105 — the
|
||||
user_id doubles as a filesystem-safe partition key). Both
|
||||
``adapters.local_storage`` and ``config.run_config`` import from here so
|
||||
there is exactly one generator left in the tree.
|
||||
"""
|
||||
|
||||
import re
|
||||
import secrets
|
||||
|
||||
ALPHABET = "abcdefghjkmnpqrstuvwxyz23456789"
|
||||
|
||||
SLUG_RE = re.compile(r"^[a-z0-9]{8}$")
|
||||
|
||||
|
||||
def generate_slug() -> str:
|
||||
"""Generate a random 8-char slug from the shared alphabet."""
|
||||
return "".join(secrets.choice(ALPHABET) for _ in range(8))
|
||||
|
||||
|
||||
def is_valid_slug(value: str) -> bool:
|
||||
"""Return True when *value* is a well-formed 8-char slug."""
|
||||
return SLUG_RE.match(value) is not None
|
||||
@@ -0,0 +1,31 @@
|
||||
"""Data-root resolution (D-107).
|
||||
|
||||
All mutable, host-persisted state (users.json, sessions.json, runs/,
|
||||
keys/, settings/) lives under one directory:
|
||||
|
||||
- ``RESEARCHX_DATA_DIR`` when set (container: ``/var/lib/researchx``),
|
||||
- else ``backend/var/researchx`` relative to the repository root (dev).
|
||||
|
||||
Replaces the cwd-dependent ``Path.cwd() / ".researchx"`` storage default.
|
||||
"""
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
_ENV_VAR = "RESEARCHX_DATA_DIR"
|
||||
|
||||
# src/researchx/config/paths.py -> repo root is four parents up.
|
||||
_REPO_ROOT = Path(__file__).resolve().parents[4]
|
||||
_DEV_DEFAULT = _REPO_ROOT / "backend" / "var" / "researchx"
|
||||
|
||||
|
||||
def data_root() -> Path:
|
||||
"""Return the mutable data root, creating it on demand.
|
||||
|
||||
The directory (and its parent chain) is created lazily so that
|
||||
importing this module never touches the filesystem.
|
||||
"""
|
||||
raw = os.environ.get(_ENV_VAR)
|
||||
root = Path(raw).expanduser() if raw else _DEV_DEFAULT
|
||||
root.mkdir(parents=True, exist_ok=True)
|
||||
return root
|
||||
@@ -0,0 +1,53 @@
|
||||
from collections.abc import Mapping
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
from researchx.config.dials import Dials
|
||||
from researchx.config.ids import generate_slug
|
||||
from researchx.methodologies.registry import MethodologyRegistry
|
||||
|
||||
|
||||
class UnknownOverrideError(ValueError):
|
||||
"""Raised when an override key is not a dial name."""
|
||||
|
||||
|
||||
class RunConfig(BaseModel, frozen=True):
|
||||
"""Persisted per-run snapshot of resolved dials (REQ-04)."""
|
||||
|
||||
run_id: str = Field(pattern=r"^[a-z0-9]{8}$")
|
||||
methodology_slug: str
|
||||
question: str = Field(min_length=1)
|
||||
context: str = ""
|
||||
resolved_dials: Dials
|
||||
created_at: datetime
|
||||
overrides: dict[str, Any] = Field(default_factory=dict)
|
||||
# Owning user (REQ-71). Optional = serialization-compatible with
|
||||
# pre-v0.3 config files (D-106: no migration).
|
||||
owner_id: str | None = None
|
||||
|
||||
model_config = ConfigDict(frozen=True)
|
||||
|
||||
|
||||
def resolve_dials(
|
||||
methodology_slug: str,
|
||||
overrides: Mapping[str, Any],
|
||||
registry: MethodologyRegistry,
|
||||
) -> Dials:
|
||||
"""Resolve a methodology's dial bundle with validated overrides applied."""
|
||||
bundle = registry.get(methodology_slug).dials
|
||||
for key in overrides:
|
||||
if key not in Dials.model_fields:
|
||||
raise UnknownOverrideError(f"unknown dial override: {key}")
|
||||
merged = bundle.model_dump()
|
||||
merged.update(overrides)
|
||||
return Dials.model_validate(merged)
|
||||
|
||||
|
||||
def new_run_id() -> str:
|
||||
"""Generate a run id (P-14: single shared generator — config/ids.py)."""
|
||||
return generate_slug()
|
||||
|
||||
|
||||
__all__ = ["RunConfig", "UnknownOverrideError", "new_run_id", "resolve_dials"]
|
||||
@@ -0,0 +1,373 @@
|
||||
import asyncio
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
|
||||
from researchx.adapters.local_storage import LocalStorageAdapter
|
||||
from researchx.adapters.model import ModelAdapter
|
||||
from researchx.adapters.search import SearchAdapter
|
||||
from researchx.adapters.types import AdapterError, ModelMessage
|
||||
from researchx.adapters.url_guard import url_allowed
|
||||
from researchx.config.dials import Dials
|
||||
from researchx.config.run_config import RunConfig, resolve_dials
|
||||
from researchx.engine.events import EventBus, RunEvent
|
||||
from researchx.engine.run_state import Plan, RunState
|
||||
from researchx.engine.states import RunStateName
|
||||
from researchx.evidence.store import EvidenceStore
|
||||
from researchx.methodologies import MethodologyRegistry
|
||||
from researchx.trust.ledger import build_ledger, export_checklist
|
||||
from researchx.trust.liveness import liveness_report
|
||||
|
||||
|
||||
class EngineError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class RunActor:
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
run_config: RunConfig,
|
||||
model: ModelAdapter,
|
||||
search: SearchAdapter,
|
||||
storage: LocalStorageAdapter,
|
||||
registry: MethodologyRegistry,
|
||||
event_bus: EventBus,
|
||||
) -> None:
|
||||
self._config = run_config
|
||||
self._model = model
|
||||
self._search = search
|
||||
self._storage = storage
|
||||
self._registry = registry
|
||||
self._bus = event_bus
|
||||
self._store = EvidenceStore()
|
||||
self.state = RunState(
|
||||
run_id=run_config.run_id,
|
||||
question=run_config.question,
|
||||
context=run_config.context,
|
||||
methodology_slug=run_config.methodology_slug,
|
||||
)
|
||||
methodology = registry.get(run_config.methodology_slug)
|
||||
self._dials: Dials = run_config.resolved_dials
|
||||
self.state.depth_target = self._dials.depth
|
||||
self._methodology = methodology
|
||||
self._pause = asyncio.Event()
|
||||
self._pause.set()
|
||||
self._cancelled = False
|
||||
self._approved = asyncio.Event()
|
||||
|
||||
async def _publish(self, event_type: str, data: dict[str, str] | None = None) -> None:
|
||||
event = RunEvent(
|
||||
type=event_type,
|
||||
run_id=self._config.run_id,
|
||||
data=data or {},
|
||||
)
|
||||
await self._bus.publish(event)
|
||||
try:
|
||||
await self._storage.append_run_artifact(
|
||||
self._config.run_id,
|
||||
"trace.jsonl",
|
||||
event.model_dump_json() + "\n",
|
||||
)
|
||||
except AdapterError:
|
||||
pass
|
||||
|
||||
async def _persist(self) -> None:
|
||||
content = self._snapshot()
|
||||
await self._storage.write_run_artifact(self._config.run_id, "state.json", content)
|
||||
|
||||
def _snapshot(self) -> str:
|
||||
return self.state.model_dump_json()
|
||||
|
||||
async def _checkpoint(self, target: RunStateName) -> None:
|
||||
if self.state.state != target:
|
||||
self.state.transition(target)
|
||||
await self._persist()
|
||||
|
||||
async def _wait_if_paused(self) -> None:
|
||||
await self._pause.wait()
|
||||
|
||||
def pause(self) -> None:
|
||||
self._pause.clear()
|
||||
|
||||
def resume(self) -> None:
|
||||
self._pause.set()
|
||||
|
||||
def cancel(self) -> None:
|
||||
self._cancelled = True
|
||||
|
||||
async def approve(self, plan: Plan | None = None) -> None:
|
||||
if plan is not None:
|
||||
self.state.plan = plan
|
||||
self._approved.set()
|
||||
|
||||
async def run(self) -> RunState:
|
||||
await self._publish("run_started", {"question": self._config.question})
|
||||
try:
|
||||
await self._execute()
|
||||
except asyncio.CancelledError:
|
||||
await asyncio.shield(self._persist_cancelled())
|
||||
raise
|
||||
except AdapterError as exc:
|
||||
await self._fail(str(exc))
|
||||
raise EngineError(str(exc)) from exc
|
||||
except httpx.HTTPError as exc:
|
||||
await self._fail(f"network error ({type(exc).__name__})")
|
||||
raise EngineError(f"network error ({type(exc).__name__})") from exc
|
||||
except EngineError:
|
||||
await self._fail("engine error")
|
||||
raise
|
||||
except Exception as exc:
|
||||
await self._fail(f"unexpected engine failure ({type(exc).__name__})")
|
||||
raise EngineError(f"unexpected engine failure ({type(exc).__name__})") from exc
|
||||
return self.state
|
||||
|
||||
async def attach_probe(self, probe_data: dict[str, Any]) -> None:
|
||||
self.state.probe = probe_data
|
||||
await self._persist()
|
||||
await self._publish("probe_attached", {"endpoint": str(probe_data.get("endpoint", ""))})
|
||||
|
||||
async def _persist_cancelled(self) -> None:
|
||||
self.state.state = RunStateName.CANCELLED
|
||||
await self._persist()
|
||||
|
||||
async def _fail(self, message: str) -> None:
|
||||
await self._publish("run_error", {"message": message})
|
||||
self.state.state = RunStateName.FAILED
|
||||
await asyncio.shield(self._persist())
|
||||
|
||||
async def _execute(self) -> None:
|
||||
current = self.state.state
|
||||
if current == RunStateName.PLANNING:
|
||||
await self._planning_phase()
|
||||
elif current == RunStateName.AWAITING_APPROVAL:
|
||||
pass
|
||||
elif current in (
|
||||
RunStateName.SEARCHING,
|
||||
RunStateName.EXTRACTING,
|
||||
RunStateName.REFLECTING,
|
||||
RunStateName.SYNTHESIZING,
|
||||
):
|
||||
await self._research_phase()
|
||||
await self._verify_phase()
|
||||
return
|
||||
elif current == RunStateName.VERIFYING:
|
||||
await self._verify_phase()
|
||||
return
|
||||
else:
|
||||
raise EngineError(f"cannot resume from state {current}")
|
||||
await self._approval_phase()
|
||||
await self._research_phase()
|
||||
await self._verify_phase()
|
||||
|
||||
async def _planning_phase(self) -> None:
|
||||
await self._checkpoint(RunStateName.PLANNING)
|
||||
await self._wait_if_paused()
|
||||
if self._cancelled:
|
||||
await self._persist_cancelled()
|
||||
raise asyncio.CancelledError
|
||||
prompt = self._methodology.planner_prompt.format(
|
||||
question=self._config.question, context=self._config.context
|
||||
)
|
||||
result = await self._model.chat(
|
||||
[ModelMessage(role="user", content=prompt)],
|
||||
model=self._model.roles().planner,
|
||||
json_mode=True,
|
||||
)
|
||||
plan = self._parse_plan(result.content)
|
||||
self.state.plan = plan
|
||||
await self._publish("state_changed", {"state": "AWAITING_APPROVAL"})
|
||||
|
||||
def _parse_plan(self, raw: str) -> Plan:
|
||||
import json
|
||||
|
||||
try:
|
||||
data = json.loads(raw)
|
||||
except json.JSONDecodeError as exc:
|
||||
raise AdapterError("planner returned non-JSON plan") from exc
|
||||
if not isinstance(data, dict):
|
||||
raise AdapterError("planner plan is not a JSON object")
|
||||
sub_questions = data.get("sub_questions", [])
|
||||
source_universe = data.get("source_universe", [])
|
||||
outline = data.get("outline", [])
|
||||
if not (
|
||||
isinstance(sub_questions, list)
|
||||
and isinstance(source_universe, list)
|
||||
and isinstance(outline, list)
|
||||
):
|
||||
raise AdapterError("planner plan has malformed lists")
|
||||
return Plan(
|
||||
sub_questions=[str(q) for q in sub_questions if isinstance(q, str)],
|
||||
source_universe=[str(u) for u in source_universe if isinstance(u, str)],
|
||||
outline=[str(o) for o in outline if isinstance(o, str)],
|
||||
)
|
||||
|
||||
async def _approval_phase(self) -> None:
|
||||
await self._checkpoint(RunStateName.AWAITING_APPROVAL)
|
||||
while not self._approved.is_set():
|
||||
if self._cancelled:
|
||||
await self._persist_cancelled()
|
||||
raise asyncio.CancelledError
|
||||
try:
|
||||
await asyncio.wait_for(self._approved.wait(), timeout=0.1)
|
||||
except TimeoutError:
|
||||
continue
|
||||
await self._checkpoint(RunStateName.SEARCHING)
|
||||
|
||||
async def _research_phase(self) -> None:
|
||||
while True:
|
||||
await self._check_cancelled()
|
||||
await self._searching_step()
|
||||
await self._extracting_step()
|
||||
if self.state.depth_cursor + 1 >= self.state.depth_target:
|
||||
break
|
||||
await self._reflecting_step()
|
||||
self.state.depth_cursor += 1
|
||||
await self._persist()
|
||||
await self._synthesizing_step()
|
||||
|
||||
async def _check_cancelled(self) -> None:
|
||||
if self._cancelled:
|
||||
await self._persist_cancelled()
|
||||
raise asyncio.CancelledError
|
||||
|
||||
async def _searching_step(self) -> None:
|
||||
if not self.state.plan or not self.state.plan.sub_questions:
|
||||
await self._checkpoint(RunStateName.SEARCHING)
|
||||
return
|
||||
await self._checkpoint(RunStateName.SEARCHING)
|
||||
await self._wait_if_paused()
|
||||
for sub_question in self.state.plan.sub_questions[: self._dials.breadth]:
|
||||
await self._publish("query_fired", {"query": sub_question})
|
||||
self.state.bump("queries")
|
||||
results = await self._search.search(sub_question, max_results=3)
|
||||
for result in results:
|
||||
await self._publish(
|
||||
"source_found",
|
||||
{"url": result.url, "title": result.title},
|
||||
)
|
||||
|
||||
async def _extracting_step(self) -> None:
|
||||
await self._checkpoint(RunStateName.EXTRACTING)
|
||||
await self._wait_if_paused()
|
||||
if not self.state.plan:
|
||||
return
|
||||
for sub_question in self.state.plan.sub_questions[: self._dials.breadth]:
|
||||
results = await self._search.search(sub_question, max_results=3)
|
||||
for result in results:
|
||||
if not url_allowed(result.url):
|
||||
await self._publish("source_refused", {"url": result.url})
|
||||
continue
|
||||
try:
|
||||
page = await self._search.fetch(result.url)
|
||||
except (AdapterError, httpx.HTTPError):
|
||||
continue
|
||||
self._store.add_page(page.url, page.title, page.text)
|
||||
await self._publish(
|
||||
"source_visited",
|
||||
{"url": page.url, "title": page.title},
|
||||
)
|
||||
self.state.bump("pages")
|
||||
for passage in self._store.passages_for_url(page.url):
|
||||
await self._publish(
|
||||
"passage_extracted",
|
||||
{"passage_id": passage.passage_id, "url": passage.url},
|
||||
)
|
||||
self.state.bump("passages")
|
||||
|
||||
async def _reflecting_step(self) -> None:
|
||||
await self._checkpoint(RunStateName.REFLECTING)
|
||||
await self._wait_if_paused()
|
||||
prompt = self._methodology.planner_prompt.format(
|
||||
question=self._config.question, context=self._config.context
|
||||
)
|
||||
result = await self._model.chat(
|
||||
[ModelMessage(role="user", content=prompt)],
|
||||
model=self._model.roles().planner,
|
||||
json_mode=True,
|
||||
)
|
||||
if self.state.plan:
|
||||
refined = self._parse_plan(result.content)
|
||||
self.state.plan = refined
|
||||
|
||||
async def _synthesizing_step(self) -> None:
|
||||
await self._checkpoint(RunStateName.SYNTHESIZING)
|
||||
await self._wait_if_paused()
|
||||
await self._check_cancelled()
|
||||
context_block = "\n\n".join(
|
||||
f"[{p.passage_id}] ({p.url}) {p.text}" for p in self._store.writer_context()
|
||||
)
|
||||
prompt = self._methodology.render_writer_prompt(
|
||||
question=self._config.question,
|
||||
context=self._config.context or "(none)",
|
||||
plan=self.state.plan.model_dump_json() if self.state.plan else "{}",
|
||||
evidence=context_block or "(no evidence collected)",
|
||||
)
|
||||
result = await self._model.chat(
|
||||
[ModelMessage(role="user", content=prompt)],
|
||||
model=self._model.roles().writer,
|
||||
)
|
||||
report = result.content
|
||||
await self._storage.write_run_artifact(self._config.run_id, "report.md", report)
|
||||
self.state.bump("writer_calls")
|
||||
|
||||
async def _verify_phase(self) -> None:
|
||||
await self._checkpoint(RunStateName.VERIFYING)
|
||||
report = await self._storage.read_run_artifact(self._config.run_id, "report.md")
|
||||
liveness = await liveness_report(self._cited_urls(report))
|
||||
ledger = build_ledger(
|
||||
run_id=self._config.run_id,
|
||||
report_text=report,
|
||||
evidence=self._store,
|
||||
liveness=liveness,
|
||||
visited_at=datetime.now(UTC),
|
||||
)
|
||||
await self._storage.write_run_artifact(
|
||||
self._config.run_id, "ledger.json", ledger.model_dump_json(indent=2)
|
||||
)
|
||||
checklist = export_checklist(ledger)
|
||||
await self._storage.write_run_artifact(self._config.run_id, "checklist.md", checklist)
|
||||
await self._checkpoint(RunStateName.DONE)
|
||||
await self._publish("run_done", {"run_id": self._config.run_id})
|
||||
|
||||
def _cited_urls(self, report_text: str) -> list[str]:
|
||||
import re
|
||||
|
||||
pattern = re.compile(r"\[p-(\d+)\]")
|
||||
cited: list[str] = []
|
||||
for match in pattern.finditer(report_text):
|
||||
passage = self._store.get(f"p-{match.group(1)}")
|
||||
if passage and passage.url not in cited:
|
||||
cited.append(passage.url)
|
||||
return cited
|
||||
|
||||
|
||||
def make_run_config(
|
||||
*,
|
||||
question: str,
|
||||
context: str,
|
||||
methodology_slug: str,
|
||||
overrides: dict[str, object],
|
||||
registry: MethodologyRegistry,
|
||||
run_id: str | None = None,
|
||||
owner_id: str | None = None,
|
||||
) -> tuple[str, RunConfig]:
|
||||
"""Build a run config; ``owner_id`` (when given) travels with the
|
||||
config and is stamped on the run state by the wiring layer (T-1.7).
|
||||
"""
|
||||
from researchx.adapters.local_storage import generate_run_id
|
||||
|
||||
rid = run_id or generate_run_id()
|
||||
dials = resolve_dials(methodology_slug, overrides, registry)
|
||||
config = RunConfig(
|
||||
run_id=rid,
|
||||
methodology_slug=methodology_slug,
|
||||
question=question,
|
||||
context=context,
|
||||
resolved_dials=dials,
|
||||
overrides=dict(overrides),
|
||||
created_at=datetime.now(UTC),
|
||||
owner_id=owner_id,
|
||||
)
|
||||
return rid, config
|
||||
@@ -0,0 +1,70 @@
|
||||
import asyncio
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
from researchx.adapters.fakes import CORPUS, FakeModelAdapter, FakeSearchAdapter
|
||||
from researchx.adapters.local_storage import LocalStorageAdapter
|
||||
from researchx.adapters.types import ChatResult
|
||||
from researchx.engine.actor import RunActor, make_run_config
|
||||
from researchx.engine.events import EventBus
|
||||
from researchx.methodologies import registry
|
||||
|
||||
|
||||
async def main() -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
base = Path(tmp)
|
||||
owner_id = "demo0001" # 8-char owner slug (P-9: demo passes the owner)
|
||||
run_id, config = make_run_config(
|
||||
question="How do verification pipelines keep citations honest?",
|
||||
context="",
|
||||
methodology_slug="rapid-answer",
|
||||
overrides={},
|
||||
registry=registry,
|
||||
owner_id=owner_id,
|
||||
)
|
||||
plan = (
|
||||
'{"sub_questions": ["verification passes", "quote anchoring"], '
|
||||
'"source_universe": ["web"], "outline": ["intro", "findings"]}'
|
||||
)
|
||||
writer = (
|
||||
"# Research Report\n\n"
|
||||
"The verification pass re-anchored every quoted claim [p-1] "
|
||||
"before the report shipped [p-2].\n"
|
||||
)
|
||||
model = FakeModelAdapter(
|
||||
canned=[
|
||||
ChatResult(content=plan, model="fake-planner"),
|
||||
ChatResult(content=writer, model="fake-writer"),
|
||||
]
|
||||
)
|
||||
search = FakeSearchAdapter(CORPUS)
|
||||
storage = LocalStorageAdapter(base_dir=base, owner_id=owner_id)
|
||||
bus = EventBus()
|
||||
actor = RunActor(
|
||||
run_config=config, model=model, search=search, storage=storage, registry=registry,
|
||||
event_bus=bus,
|
||||
)
|
||||
task = asyncio.create_task(actor.run())
|
||||
while actor.state.state.value == "PLANNING":
|
||||
await asyncio.sleep(0.01)
|
||||
await actor.approve()
|
||||
await task
|
||||
|
||||
state = (storage.run_dir(run_id) / "state.json").read_text()
|
||||
report = (storage.run_dir(run_id) / "report.md").read_text()
|
||||
ledger = (storage.run_dir(run_id) / "ledger.json").read_text()
|
||||
checklist = (storage.run_dir(run_id) / "checklist.md").read_text()
|
||||
print(f"run {run_id} → {actor.state.state.value}")
|
||||
print(f"artifacts under {storage.run_dir(run_id)}:")
|
||||
for name, content in [
|
||||
("state.json", state),
|
||||
("report.md", report),
|
||||
("ledger.json", ledger),
|
||||
("checklist.md", checklist),
|
||||
]:
|
||||
print(f"--- {name} ({len(content)} chars) ---")
|
||||
print(f"events: {len(bus.replay())}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
@@ -0,0 +1,49 @@
|
||||
from collections.abc import Awaitable, Callable
|
||||
from datetime import UTC, datetime
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class RunEvent(BaseModel):
|
||||
type: str
|
||||
run_id: str
|
||||
at: datetime = Field(default_factory=lambda: datetime.now(UTC))
|
||||
data: dict[str, str] = {}
|
||||
seq: int = 0
|
||||
|
||||
|
||||
class EventBus:
|
||||
def __init__(self) -> None:
|
||||
self._subscribers: list[Callable[[RunEvent], Awaitable[None]]] = []
|
||||
self._log: list[RunEvent] = []
|
||||
self._seq = 0
|
||||
|
||||
def subscribe(self, handler: Callable[[RunEvent], Awaitable[None]]) -> None:
|
||||
self._subscribers.append(handler)
|
||||
|
||||
def unsubscribe(self, handler: Callable[[RunEvent], Awaitable[None]]) -> None:
|
||||
if handler in self._subscribers:
|
||||
self._subscribers.remove(handler)
|
||||
|
||||
async def publish(self, event: RunEvent) -> None:
|
||||
self._seq += 1
|
||||
event.seq = self._seq
|
||||
self._log.append(event)
|
||||
for handler in self._subscribers:
|
||||
await handler(event)
|
||||
|
||||
def replay(self, after_seq: int = 0) -> list[RunEvent]:
|
||||
return [e for e in self._log if e.seq > after_seq]
|
||||
|
||||
|
||||
class TraceWriter:
|
||||
def __init__(self, emit: Callable[[str], None]) -> None:
|
||||
self._emit = emit
|
||||
|
||||
async def record(self, event: RunEvent) -> None:
|
||||
self._emit(event.model_dump_json())
|
||||
|
||||
|
||||
def event_type_of(event: RunEvent) -> Literal["run_started", "other"]:
|
||||
return "run_started" if event.type == "run_started" else "other"
|
||||
@@ -0,0 +1,43 @@
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from researchx.engine.states import RunStateName, can_transition
|
||||
|
||||
|
||||
class Plan(BaseModel):
|
||||
sub_questions: list[str] = Field(default_factory=list)
|
||||
source_universe: list[str] = Field(default_factory=list)
|
||||
outline: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class RunState(BaseModel):
|
||||
run_id: str
|
||||
question: str
|
||||
context: str = ""
|
||||
methodology_slug: str
|
||||
state: RunStateName = RunStateName.PLANNING
|
||||
# Owner of the run (REQ-71). None on pre-v0.3 state files — old
|
||||
# artifacts must deserialize unchanged (D-106: no migration).
|
||||
owner_id: str | None = None
|
||||
# Which credential tier resolved for this run (REQ-74: user|
|
||||
# server|keyless). None on pre-v0.3.2 state files — old artifacts
|
||||
# deserialize unchanged (D-106: no migration).
|
||||
credential_tier: str | None = None
|
||||
plan: Plan | None = None
|
||||
depth_cursor: int = 0
|
||||
depth_target: int = 1
|
||||
counters: dict[str, int] = Field(default_factory=dict)
|
||||
probe: dict[str, Any] | None = None
|
||||
created_at: datetime = Field(default_factory=lambda: datetime.now(UTC))
|
||||
updated_at: datetime = Field(default_factory=lambda: datetime.now(UTC))
|
||||
|
||||
def transition(self, target: RunStateName) -> None:
|
||||
if not can_transition(self.state, target):
|
||||
raise ValueError(f"illegal transition: {self.state} -> {target}")
|
||||
self.state = target
|
||||
self.updated_at = datetime.now(UTC)
|
||||
|
||||
def bump(self, key: str) -> None:
|
||||
self.counters[key] = self.counters.get(key, 0) + 1
|
||||
@@ -0,0 +1,72 @@
|
||||
from enum import StrEnum
|
||||
|
||||
|
||||
class RunStateName(StrEnum):
|
||||
PLANNING = "PLANNING"
|
||||
AWAITING_APPROVAL = "AWAITING_APPROVAL"
|
||||
SEARCHING = "SEARCHING"
|
||||
EXTRACTING = "EXTRACTING"
|
||||
REFLECTING = "REFLECTING"
|
||||
SYNTHESIZING = "SYNTHESIZING"
|
||||
VERIFYING = "VERIFYING"
|
||||
DONE = "DONE"
|
||||
CANCELLED = "CANCELLED"
|
||||
FAILED = "FAILED"
|
||||
|
||||
|
||||
_LEGAL_TRANSITIONS: dict[RunStateName, set[RunStateName]] = {
|
||||
# G-101: PLANNING -> CANCELLED is legal — the restart sweep rewrites
|
||||
# in-flight PLANNING runs as CANCELLED; semantically a run that has
|
||||
# not produced a plan can always be abandoned.
|
||||
RunStateName.PLANNING: {
|
||||
RunStateName.AWAITING_APPROVAL,
|
||||
RunStateName.CANCELLED,
|
||||
RunStateName.FAILED,
|
||||
},
|
||||
RunStateName.AWAITING_APPROVAL: {
|
||||
RunStateName.SEARCHING,
|
||||
RunStateName.CANCELLED,
|
||||
RunStateName.FAILED,
|
||||
},
|
||||
RunStateName.SEARCHING: {
|
||||
RunStateName.EXTRACTING,
|
||||
RunStateName.SYNTHESIZING,
|
||||
RunStateName.CANCELLED,
|
||||
RunStateName.FAILED,
|
||||
},
|
||||
RunStateName.EXTRACTING: {
|
||||
RunStateName.REFLECTING,
|
||||
RunStateName.SYNTHESIZING,
|
||||
RunStateName.CANCELLED,
|
||||
RunStateName.FAILED,
|
||||
},
|
||||
RunStateName.REFLECTING: {
|
||||
RunStateName.SEARCHING,
|
||||
RunStateName.SYNTHESIZING,
|
||||
RunStateName.CANCELLED,
|
||||
RunStateName.FAILED,
|
||||
},
|
||||
RunStateName.SYNTHESIZING: {
|
||||
RunStateName.VERIFYING,
|
||||
RunStateName.CANCELLED,
|
||||
RunStateName.FAILED,
|
||||
},
|
||||
RunStateName.VERIFYING: {
|
||||
RunStateName.DONE,
|
||||
RunStateName.CANCELLED,
|
||||
RunStateName.FAILED,
|
||||
},
|
||||
RunStateName.DONE: set(),
|
||||
RunStateName.CANCELLED: set(),
|
||||
RunStateName.FAILED: set(),
|
||||
}
|
||||
|
||||
TERMINAL_STATES = {
|
||||
RunStateName.DONE,
|
||||
RunStateName.CANCELLED,
|
||||
RunStateName.FAILED,
|
||||
}
|
||||
|
||||
|
||||
def can_transition(current: RunStateName, target: RunStateName) -> bool:
|
||||
return target in _LEGAL_TRANSITIONS[current]
|
||||
@@ -0,0 +1,70 @@
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class Passage(BaseModel):
|
||||
passage_id: str
|
||||
url: str
|
||||
quote: str
|
||||
text: str
|
||||
|
||||
|
||||
class VisitedPage(BaseModel):
|
||||
url: str
|
||||
title: str
|
||||
passage_ids: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
def extract_passages(text: str, *, min_chars: int = 120) -> list[str]:
|
||||
paragraphs = [p.strip() for p in text.split("\n") if p.strip()]
|
||||
return [p for p in paragraphs if len(p) >= min_chars]
|
||||
|
||||
|
||||
class EvidenceStore:
|
||||
def __init__(self) -> None:
|
||||
self._passages: dict[str, Passage] = {}
|
||||
self._pages: dict[str, VisitedPage] = {}
|
||||
self._seen_quotes: set[str] = set()
|
||||
self._counter = 0
|
||||
|
||||
def add_page(self, url: str, title: str, text: str) -> VisitedPage:
|
||||
page = self._pages.setdefault(url, VisitedPage(url=url, title=title))
|
||||
for paragraph in extract_passages(text):
|
||||
self.add_passage(url, paragraph)
|
||||
return page
|
||||
|
||||
def add_passage(self, url: str, text: str) -> Passage:
|
||||
self._counter += 1
|
||||
passage_id = f"p-{self._counter}"
|
||||
passage = Passage(passage_id=passage_id, url=url, quote=text[:200], text=text)
|
||||
self._passages[passage_id] = passage
|
||||
page = self._pages.get(url)
|
||||
if page is None:
|
||||
page = VisitedPage(url=url, title="")
|
||||
self._pages[url] = page
|
||||
page.passage_ids.append(passage_id)
|
||||
self._seen_quotes.add(text[:200])
|
||||
return passage
|
||||
|
||||
def has_seen(self, quote: str) -> bool:
|
||||
return quote[:200] in self._seen_quotes
|
||||
|
||||
def get(self, passage_id: str) -> Passage | None:
|
||||
return self._passages.get(passage_id)
|
||||
|
||||
def passages_for_url(self, url: str) -> list[Passage]:
|
||||
page = self._pages.get(url)
|
||||
if page is None:
|
||||
return []
|
||||
return [self._passages[pid] for pid in page.passage_ids if pid in self._passages]
|
||||
|
||||
def writer_context(self) -> list[Passage]:
|
||||
return [self._passages[pid] for pid in sorted(self._passages, key=self._passage_order)]
|
||||
|
||||
def _passage_order(self, passage_id: str) -> int:
|
||||
return int(passage_id.split("-")[1])
|
||||
|
||||
def pages(self) -> list[VisitedPage]:
|
||||
return list(self._pages.values())
|
||||
|
||||
def __len__(self) -> int:
|
||||
return len(self._passages)
|
||||
@@ -0,0 +1,9 @@
|
||||
from researchx.methodologies.builtin import registry
|
||||
from researchx.methodologies.models import Methodology
|
||||
from researchx.methodologies.registry import MethodologyRegistry
|
||||
|
||||
__all__ = [
|
||||
"Methodology",
|
||||
"MethodologyRegistry",
|
||||
"registry",
|
||||
]
|
||||
@@ -0,0 +1,97 @@
|
||||
from researchx.config.dials import Dials
|
||||
from researchx.methodologies.models import Methodology
|
||||
from researchx.methodologies.registry import MethodologyRegistry
|
||||
|
||||
_RAPID_PLANNER_PROMPT = (
|
||||
"You are a rapid research planner. Decompose the user's question into the fewest "
|
||||
"focused sub-questions that yield a confident answer.\n"
|
||||
"Question: {question}\n"
|
||||
"Context: {context}"
|
||||
)
|
||||
|
||||
_RAPID_WRITER_PROMPT = (
|
||||
"You are a rapid memo writer. Answer the question directly in a short memo.\n"
|
||||
"Cite a source per claim using the passage markers [p-N]. Every cited quote must "
|
||||
"come verbatim from the evidence passages listed below and from nowhere else.\n"
|
||||
"Question: {question}\n"
|
||||
"Context: {context}\n"
|
||||
"Plan: {plan}\n"
|
||||
"Evidence passages:\n{evidence}"
|
||||
)
|
||||
|
||||
_DEEP_PLANNER_PROMPT = (
|
||||
"You are a deep research planner. Decompose the user's question into two "
|
||||
"sub-questions, then propose an outline for an executive report with "
|
||||
"sections, each mapped to the sub-questions it answers.\n"
|
||||
"Question: {question}\n"
|
||||
"Context: {context}"
|
||||
)
|
||||
|
||||
_DEEP_WRITER_PROMPT = (
|
||||
"You are an executive report writer. Synthesize the evidence into a structured "
|
||||
"executive report following the plan: executive summary, findings, risks, and "
|
||||
"recommendations.\n"
|
||||
"Cite a source per claim using the passage markers [p-N]. Every cited quote must "
|
||||
"come verbatim from the evidence passages listed below and from nowhere else.\n"
|
||||
"Question: {question}\n"
|
||||
"Context: {context}\n"
|
||||
"Plan: {plan}\n"
|
||||
"Evidence passages:\n{evidence}"
|
||||
)
|
||||
|
||||
_BUILTIN: dict[str, Methodology] = {
|
||||
"rapid-answer": Methodology(
|
||||
slug="rapid-answer",
|
||||
name="Rapid Answer",
|
||||
description=(
|
||||
"One search pass, one synthesis pass: a short cited memo for questions "
|
||||
"that need a confident answer fast."
|
||||
),
|
||||
dials=Dials(
|
||||
source_universe="web",
|
||||
search_routing="single",
|
||||
query_strategy="decomposed",
|
||||
breadth=1,
|
||||
depth=1,
|
||||
quality_gates="standard",
|
||||
citation_rigor="per-claim",
|
||||
verification_passes=1,
|
||||
synthesis_style="memo",
|
||||
output_structure="brief",
|
||||
memory_scope="none",
|
||||
checkpoints=False,
|
||||
),
|
||||
planner_prompt=_RAPID_PLANNER_PROMPT,
|
||||
writer_prompt=_RAPID_WRITER_PROMPT,
|
||||
output_shape="memo",
|
||||
),
|
||||
"deep-report": Methodology(
|
||||
slug="deep-report",
|
||||
name="Deep Report",
|
||||
description=(
|
||||
"Full-depth research: iterative sub-questions, reflective refinement, and "
|
||||
"a verified executive report with per-claim citations."
|
||||
),
|
||||
dials=Dials(
|
||||
source_universe="web+academic",
|
||||
search_routing="adaptive",
|
||||
query_strategy="decomposed",
|
||||
breadth=2,
|
||||
depth=3,
|
||||
quality_gates="strict",
|
||||
citation_rigor="per-claim",
|
||||
verification_passes=2,
|
||||
synthesis_style="executive",
|
||||
output_structure="report",
|
||||
memory_scope="none",
|
||||
checkpoints=True,
|
||||
),
|
||||
planner_prompt=_DEEP_PLANNER_PROMPT,
|
||||
writer_prompt=_DEEP_WRITER_PROMPT,
|
||||
output_shape="executive-report",
|
||||
),
|
||||
}
|
||||
|
||||
registry = MethodologyRegistry(_BUILTIN)
|
||||
|
||||
__all__ = ["registry"]
|
||||
@@ -0,0 +1,32 @@
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from researchx.config.dials import Dials
|
||||
|
||||
OutputShape = Literal["memo", "executive-report"]
|
||||
|
||||
|
||||
class Methodology(BaseModel, frozen=True):
|
||||
"""A named research methodology: a dial bundle plus prompt templates (REQ-02)."""
|
||||
|
||||
slug: str = Field(pattern=r"^[a-z0-9]+(-[a-z0-9]+)*$")
|
||||
name: str
|
||||
description: str
|
||||
dials: Dials
|
||||
planner_prompt: str
|
||||
writer_prompt: str
|
||||
output_shape: OutputShape
|
||||
|
||||
def render_planner_prompt(self, question: str, context: str) -> str:
|
||||
return self.planner_prompt.format(question=question, context=context)
|
||||
|
||||
def render_writer_prompt(
|
||||
self, question: str, context: str, plan: str, evidence: str
|
||||
) -> str:
|
||||
return self.writer_prompt.format(
|
||||
question=question, context=context, plan=plan, evidence=evidence
|
||||
)
|
||||
|
||||
|
||||
__all__ = ["Methodology", "OutputShape"]
|
||||
@@ -0,0 +1,26 @@
|
||||
from collections.abc import Mapping
|
||||
|
||||
from researchx.methodologies.models import Methodology
|
||||
|
||||
|
||||
class MethodologyRegistry:
|
||||
"""Frozen by-slug registry that hands out immutable copies (REQ-02/REQ-03)."""
|
||||
|
||||
def __init__(self, methodologies: Mapping[str, Methodology]) -> None:
|
||||
self._methodologies: dict[str, Methodology] = dict(methodologies)
|
||||
|
||||
def get(self, slug: str) -> Methodology:
|
||||
return self._methodologies[slug].model_copy(deep=True)
|
||||
|
||||
def list(self) -> list[Methodology]:
|
||||
ordered = sorted(self._methodologies.values(), key=lambda m: m.name)
|
||||
return [m.model_copy(deep=True) for m in ordered]
|
||||
|
||||
def __contains__(self, slug: str) -> bool:
|
||||
return slug in self._methodologies
|
||||
|
||||
def __len__(self) -> int:
|
||||
return len(self._methodologies)
|
||||
|
||||
|
||||
__all__ = ["MethodologyRegistry"]
|
||||
@@ -0,0 +1,89 @@
|
||||
import re
|
||||
import unicodedata
|
||||
from difflib import SequenceMatcher
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
from researchx.evidence.store import EvidenceStore, Passage
|
||||
|
||||
ANCHOR_THRESHOLD = 0.85
|
||||
WEAK_MATCH_MIN = 0.80
|
||||
|
||||
|
||||
def normalize(text: str) -> str:
|
||||
nfc = unicodedata.normalize("NFC", text)
|
||||
collapsed = re.sub(r"\s+", " ", nfc)
|
||||
return collapsed.casefold().strip()
|
||||
|
||||
|
||||
class AnchorVerdict(BaseModel):
|
||||
quote: str
|
||||
passage_id: str | None
|
||||
ratio: float
|
||||
matched: bool
|
||||
weak: bool
|
||||
|
||||
|
||||
def anchor_quote(quote: str, passages: list[Passage]) -> AnchorVerdict:
|
||||
normalized_quote = normalize(quote)
|
||||
best_ratio = 0.0
|
||||
best_id: str | None = None
|
||||
for passage in passages:
|
||||
normalized_passage = normalize(passage.text)
|
||||
if normalized_quote and normalized_quote in normalized_passage:
|
||||
return AnchorVerdict(
|
||||
quote=quote, passage_id=passage.passage_id, ratio=1.0, matched=True, weak=False
|
||||
)
|
||||
ratio = SequenceMatcher(None, normalized_quote, normalized_passage).ratio()
|
||||
if ratio > best_ratio:
|
||||
best_ratio = ratio
|
||||
best_id = passage.passage_id
|
||||
matched = best_ratio >= ANCHOR_THRESHOLD
|
||||
weak = WEAK_MATCH_MIN <= best_ratio < ANCHOR_THRESHOLD
|
||||
return AnchorVerdict(
|
||||
quote=quote,
|
||||
passage_id=best_id if (matched or weak) else None,
|
||||
ratio=round(best_ratio, 4),
|
||||
matched=matched,
|
||||
weak=weak,
|
||||
)
|
||||
|
||||
|
||||
_MARKER_RE = re.compile(r"\[p-(\d+)\]")
|
||||
_HEADER_RE = re.compile(r"^#{1,6}\s.*$", re.MULTILINE)
|
||||
|
||||
|
||||
def _claim_fragment(before: str) -> str:
|
||||
cleaned = _HEADER_RE.sub("", before).rstrip()
|
||||
fragment_start = max(
|
||||
cleaned.rfind("."), cleaned.rfind("!"), cleaned.rfind("?"), cleaned.rfind("\n")
|
||||
)
|
||||
quote_text = cleaned[fragment_start + 1 :].strip(" \t>\"'*-")
|
||||
if not quote_text:
|
||||
quote_text = cleaned[-200:].strip(" \t>\"'*-")
|
||||
return quote_text
|
||||
|
||||
|
||||
def extract_report_claims(report_text: str) -> list[tuple[str, str]]:
|
||||
"""Extract (passage_id, quote_text) claims from the report.
|
||||
|
||||
The quote text is what the WRITER actually wrote adjacent to the
|
||||
citation marker — the last sentence fragment before each [p-N]
|
||||
marker — NOT the stored passage's own text. This is the adversarial
|
||||
core of REQ-20: a fabricated quote riding a real passage ID must
|
||||
be visible to the anchorer.
|
||||
"""
|
||||
claims: list[tuple[str, str]] = []
|
||||
for match in _MARKER_RE.finditer(report_text):
|
||||
passage_id = f"p-{match.group(1)}"
|
||||
quote_text = _claim_fragment(report_text[: match.start()])
|
||||
claims.append((passage_id, quote_text))
|
||||
return claims
|
||||
|
||||
|
||||
def extract_quotes_from_report(report_text: str, evidence: EvidenceStore) -> list[str]:
|
||||
return [quote for _pid, quote in extract_report_claims(report_text) if evidence.get(_pid)]
|
||||
|
||||
|
||||
def extract_cited_passage_ids(report_text: str) -> list[str]:
|
||||
return [f"p-{m.group(1)}" for m in _MARKER_RE.finditer(report_text)]
|
||||
@@ -0,0 +1,208 @@
|
||||
import re
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
from researchx.evidence.store import EvidenceStore, Passage
|
||||
from researchx.trust.anchoring import (
|
||||
anchor_quote,
|
||||
extract_cited_passage_ids,
|
||||
extract_report_claims,
|
||||
normalize,
|
||||
)
|
||||
from researchx.trust.liveness import LivenessStatus
|
||||
|
||||
_AUTHORITY_TIERS: dict[str, str] = {
|
||||
"example.org": "reference",
|
||||
"gov": "official",
|
||||
"edu": "academic",
|
||||
"org": "reference",
|
||||
"com": "general",
|
||||
}
|
||||
|
||||
|
||||
def authority_tier(url: str) -> str:
|
||||
host = re.sub(r"^https?://", "", url).split("/")[0].lower().split(":")[0]
|
||||
labels = host.split(".")
|
||||
for suffix, tier in _AUTHORITY_TIERS.items():
|
||||
if labels[-1] == suffix or (len(labels) > 1 and labels[-2] == suffix):
|
||||
return tier
|
||||
return "general"
|
||||
|
||||
|
||||
class CitationEntry(BaseModel):
|
||||
url: str
|
||||
visited_at: datetime
|
||||
passages: list[str]
|
||||
claims_supported: int
|
||||
health: str
|
||||
authority_tier: str
|
||||
|
||||
|
||||
class VerificationChecklist(BaseModel):
|
||||
run_id: str
|
||||
citations_checked: int
|
||||
live: int
|
||||
stale: int
|
||||
dead: int
|
||||
anchored_claims: int
|
||||
unanchored_claims: int
|
||||
weak_matches: int
|
||||
confidence_labels: dict[str, str]
|
||||
liveness_ratio: float
|
||||
|
||||
|
||||
class CitationLedger(BaseModel):
|
||||
run_id: str
|
||||
generated_at: datetime
|
||||
entries: list[CitationEntry]
|
||||
checklist: VerificationChecklist
|
||||
|
||||
|
||||
_CLAIM_RE = re.compile(r"\[p-(\d+)\]")
|
||||
|
||||
|
||||
def _claims_per_passage(report_text: str) -> dict[str, int]:
|
||||
counts: dict[str, int] = {}
|
||||
for match in _CLAIM_RE.finditer(report_text):
|
||||
pid = f"p-{match.group(1)}"
|
||||
counts[pid] = counts.get(pid, 0) + 1
|
||||
return counts
|
||||
|
||||
|
||||
def build_ledger(
|
||||
*,
|
||||
run_id: str,
|
||||
report_text: str,
|
||||
evidence: EvidenceStore,
|
||||
liveness: dict[str, LivenessStatus],
|
||||
visited_at: datetime,
|
||||
) -> CitationLedger:
|
||||
claims = _claims_per_passage(report_text)
|
||||
all_passages = evidence.writer_context()
|
||||
report_claims = extract_report_claims(report_text)
|
||||
cited_ids = extract_cited_passage_ids(report_text)
|
||||
known_ids = {p.passage_id for p in all_passages}
|
||||
cited_urls: list[str] = []
|
||||
entries: list[CitationEntry] = []
|
||||
anchored = 0
|
||||
unanchored = 0
|
||||
weak = 0
|
||||
confidence: dict[str, str] = {}
|
||||
|
||||
for passage_id, writer_quote in report_claims:
|
||||
passage = evidence.get(passage_id)
|
||||
if passage is None:
|
||||
unanchored += 1
|
||||
continue
|
||||
verdict = anchor_quote(writer_quote, [passage])
|
||||
if verdict.matched:
|
||||
anchored += 1
|
||||
elif verdict.weak:
|
||||
weak += 1
|
||||
else:
|
||||
unanchored += 1
|
||||
|
||||
for cited_id in cited_ids:
|
||||
if cited_id not in known_ids:
|
||||
unanchored += 1
|
||||
passage = evidence.get(cited_id)
|
||||
if passage is not None and passage.url not in cited_urls:
|
||||
cited_urls.append(passage.url)
|
||||
|
||||
for page in evidence.pages():
|
||||
if page.url not in cited_urls:
|
||||
continue
|
||||
passages = evidence.passages_for_url(page.url)
|
||||
passage_ids = [p.passage_id for p in passages]
|
||||
page_claims = sum(claims.get(pid, 0) for pid in passage_ids)
|
||||
health = str(liveness.get(page.url, LivenessStatus.DEAD))
|
||||
entries.append(
|
||||
CitationEntry(
|
||||
url=page.url,
|
||||
visited_at=visited_at,
|
||||
passages=passage_ids,
|
||||
claims_supported=page_claims,
|
||||
health=health,
|
||||
authority_tier=authority_tier(page.url),
|
||||
)
|
||||
)
|
||||
for passage in passages:
|
||||
confidence[passage.passage_id] = (
|
||||
"corroborated" if _corroborated(passage, evidence) else "single-source"
|
||||
)
|
||||
|
||||
live = sum(1 for e in entries if e.health == "live")
|
||||
stale = sum(1 for e in entries if e.health == "stale")
|
||||
dead = sum(1 for e in entries if e.health == "dead")
|
||||
total = len(entries)
|
||||
ratio = round(live / total, 4) if total > 0 else 1.0
|
||||
checklist = VerificationChecklist(
|
||||
run_id=run_id,
|
||||
citations_checked=total,
|
||||
live=live,
|
||||
stale=stale,
|
||||
dead=dead,
|
||||
anchored_claims=anchored,
|
||||
unanchored_claims=unanchored,
|
||||
weak_matches=weak,
|
||||
confidence_labels=confidence,
|
||||
liveness_ratio=ratio,
|
||||
)
|
||||
return CitationLedger(
|
||||
run_id=run_id, generated_at=visited_at, entries=entries, checklist=checklist
|
||||
)
|
||||
|
||||
|
||||
def _corroborated(passage: Passage, evidence: EvidenceStore) -> bool:
|
||||
normalized = normalize(passage.text)
|
||||
for other in evidence.writer_context():
|
||||
if other.passage_id == passage.passage_id:
|
||||
continue
|
||||
if normalized in normalize(other.text):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
LIVENESS_EXPORT_GATE = 0.99
|
||||
|
||||
|
||||
def liveness_export_gate(ledger: CitationLedger) -> tuple[bool, list[str]]:
|
||||
failures = [e.url for e in ledger.entries if e.health != "live"]
|
||||
liveness_ok = ledger.checklist.liveness_ratio >= LIVENESS_EXPORT_GATE
|
||||
anchoring_ok = ledger.checklist.unanchored_claims == 0
|
||||
return liveness_ok and anchoring_ok, failures
|
||||
|
||||
|
||||
def export_checklist(ledger: CitationLedger) -> str:
|
||||
gate_passed, failures = liveness_export_gate(ledger)
|
||||
lines = [
|
||||
"# Verification Checklist",
|
||||
"",
|
||||
f"Run: {ledger.run_id}",
|
||||
f"Citations checked: {ledger.checklist.citations_checked}",
|
||||
f"Live: {ledger.checklist.live} / Stale: {ledger.checklist.stale} / Dead: {ledger.checklist.dead}",
|
||||
(
|
||||
f"Liveness ratio: {ledger.checklist.liveness_ratio} (export gate >= {LIVENESS_EXPORT_GATE}: "
|
||||
f"{'PASS' if gate_passed else 'FAIL'})"
|
||||
),
|
||||
(
|
||||
f"Anchoring gate: {'PASS' if ledger.checklist.unanchored_claims == 0 else 'FAIL'} "
|
||||
f"({ledger.checklist.unanchored_claims} unanchored)"
|
||||
),
|
||||
f"Anchored claims: {ledger.checklist.anchored_claims}",
|
||||
f"Unanchored claims: {ledger.checklist.unanchored_claims}",
|
||||
f"Weak matches (flagged, not accepted): {ledger.checklist.weak_matches}",
|
||||
"",
|
||||
"## Per-URL ledger",
|
||||
"",
|
||||
]
|
||||
for entry in ledger.entries:
|
||||
lines.append(
|
||||
f"- {entry.url} — health: {entry.health}, tier: {entry.authority_tier}, "
|
||||
f"passages: {len(entry.passages)}, claims: {entry.claims_supported}"
|
||||
)
|
||||
if failures:
|
||||
lines += ["", "## Liveness failures", ""]
|
||||
lines += [f"- {url}" for url in failures]
|
||||
return "\n".join(lines) + "\n"
|
||||
@@ -0,0 +1,65 @@
|
||||
from enum import StrEnum
|
||||
|
||||
import httpx
|
||||
|
||||
from researchx.adapters.url_guard import url_allowed
|
||||
|
||||
_LIVENESS_TIMEOUT = 10.0
|
||||
_MAX_RESPONSE_BYTES = 2 * 1024 * 1024
|
||||
_MAX_REDIRECTS = 5
|
||||
_USER_AGENT = "researchx/0.2 (liveness check)"
|
||||
|
||||
|
||||
class LivenessStatus(StrEnum):
|
||||
LIVE = "live"
|
||||
STALE = "stale"
|
||||
DEAD = "dead"
|
||||
|
||||
|
||||
async def check_liveness(url: str, client: httpx.AsyncClient | None = None) -> LivenessStatus:
|
||||
own_client = client is None
|
||||
active: httpx.AsyncClient = client or httpx.AsyncClient(
|
||||
timeout=_LIVENESS_TIMEOUT,
|
||||
follow_redirects=False,
|
||||
)
|
||||
current_url = url
|
||||
try:
|
||||
for _ in range(_MAX_REDIRECTS + 1):
|
||||
if not url_allowed(current_url):
|
||||
return LivenessStatus.DEAD
|
||||
response = await active.get(
|
||||
current_url,
|
||||
headers={"User-Agent": _USER_AGENT},
|
||||
)
|
||||
if 300 <= response.status_code < 400:
|
||||
location = response.headers.get("location")
|
||||
if not location:
|
||||
return LivenessStatus.STALE
|
||||
from urllib.parse import urljoin
|
||||
|
||||
current_url = urljoin(current_url, location)
|
||||
continue
|
||||
content = b""
|
||||
async for chunk in response.aiter_bytes():
|
||||
content += chunk
|
||||
if len(content) > _MAX_RESPONSE_BYTES:
|
||||
break
|
||||
status = response.status_code
|
||||
if 200 <= status < 300:
|
||||
return LivenessStatus.LIVE
|
||||
return LivenessStatus.DEAD
|
||||
return LivenessStatus.STALE
|
||||
except (httpx.HTTPError, OSError):
|
||||
return LivenessStatus.DEAD
|
||||
finally:
|
||||
if own_client:
|
||||
await active.aclose()
|
||||
|
||||
|
||||
async def liveness_report(
|
||||
urls: list[str],
|
||||
) -> dict[str, LivenessStatus]:
|
||||
report: dict[str, LivenessStatus] = {}
|
||||
for url in dict.fromkeys(urls):
|
||||
report[url] = await check_liveness(url)
|
||||
return report
|
||||
@@ -0,0 +1,22 @@
|
||||
"""Shared test fixtures."""
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _reset_sse_starlette_loop_binding():
|
||||
"""Reset sse_starlette's module-global shutdown Event before each test.
|
||||
|
||||
``sse_starlette.AppStatus.should_exit_event`` is an ``anyio.Event``
|
||||
memoized at the first SSE response and bound to that event loop.
|
||||
pytest-asyncio gives every test a fresh loop, so a second SSE test in
|
||||
one process would die with "Event is bound to a different event
|
||||
loop". Resetting the global before each test keeps the library's
|
||||
loop-binding per-test. (Production is unaffected: single process,
|
||||
single loop, single uvicorn worker by design.)
|
||||
"""
|
||||
from sse_starlette.sse import AppStatus
|
||||
|
||||
AppStatus.should_exit = False
|
||||
AppStatus.should_exit_event = None
|
||||
yield
|
||||
@@ -0,0 +1,21 @@
|
||||
import secrets as secrets_module
|
||||
from string import ascii_lowercase
|
||||
|
||||
from researchx.adapters.local_storage import generate_run_id
|
||||
|
||||
|
||||
def test_generate_run_id_format() -> None:
|
||||
run_id = generate_run_id()
|
||||
assert isinstance(run_id, str)
|
||||
assert len(run_id) == 8
|
||||
assert all(c in ascii_lowercase + "23456789" for c in run_id)
|
||||
assert "o" not in run_id and "l" not in run_id and "0" not in run_id and "1" not in run_id
|
||||
|
||||
|
||||
def test_generate_run_id_entropy() -> None:
|
||||
ids = {generate_run_id() for _ in range(100)}
|
||||
assert len(ids) == 100
|
||||
|
||||
|
||||
def test_generate_run_id_uses_secrets_module() -> None:
|
||||
assert secrets_module.choice is not None
|
||||
@@ -0,0 +1,363 @@
|
||||
"""Adversarial suite (REQ-32): attacks on the trust loop, proven caught.
|
||||
|
||||
Every test here is an attempt to fool the verifier. If one passes
|
||||
without being flagged, the trust loop is broken — the suite fails.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
|
||||
import httpx
|
||||
|
||||
from researchx.adapters.fakes import CORPUS, FakeModelAdapter, FakeSearchAdapter
|
||||
from researchx.adapters.types import AdapterError, ChatResult
|
||||
from researchx.api.app import create_app
|
||||
from researchx.evidence.store import EvidenceStore
|
||||
from researchx.trust.anchoring import anchor_quote
|
||||
from researchx.trust.ledger import build_ledger, export_checklist, liveness_export_gate
|
||||
from researchx.trust.liveness import LivenessStatus, check_liveness
|
||||
|
||||
|
||||
def test_attack_hallucinated_url_fails_export_gate() -> None:
|
||||
store = EvidenceStore()
|
||||
store.add_passage("https://real.example.org/a", "A real stored passage about the topic " * 5)
|
||||
report = (
|
||||
"# Report\n\n"
|
||||
"Real claim [p-1].\n\n"
|
||||
"Supporting detail from a hallucinated source [p-2].\n"
|
||||
)
|
||||
ledger = build_ledger(
|
||||
run_id="attack001",
|
||||
report_text=report,
|
||||
evidence=store,
|
||||
liveness={"https://real.example.org/a": LivenessStatus.LIVE},
|
||||
visited_at=datetime.now(UTC),
|
||||
)
|
||||
claim_counts = _claim_counts(ledger)
|
||||
assert claim_counts.get("p-1", 0) >= 1
|
||||
assert "p-2" in report
|
||||
assert not any("p-2" in pids for pids in [e.passages for e in ledger.entries]), (
|
||||
"hallucinated passage id must not appear in any ledger entry"
|
||||
)
|
||||
unanchored = ledger.checklist.unanchored_claims
|
||||
assert unanchored >= 1, "hallucinated citation must be flagged as unanchored"
|
||||
|
||||
|
||||
def _claim_counts(ledger) -> dict[str, int]:
|
||||
counts: dict[str, int] = {}
|
||||
for entry in ledger.entries:
|
||||
for pid in entry.passages:
|
||||
counts[pid] = counts.get(pid, 0) + entry.claims_supported
|
||||
return counts
|
||||
|
||||
|
||||
def test_attack_fabricated_quote_flagged_not_accepted() -> None:
|
||||
store = EvidenceStore()
|
||||
for page in CORPUS:
|
||||
store.add_page(page.url, page.title, page.text)
|
||||
passages = store.writer_context()
|
||||
|
||||
fabricated = "This sentence appears in exactly zero stored passages anywhere."
|
||||
verdict = anchor_quote(fabricated, passages)
|
||||
assert verdict.matched is False
|
||||
assert verdict.weak is False
|
||||
|
||||
near_miss = passages[0].text[: len(passages[0].text) // 2] + " And a divergent clause."
|
||||
near_verdict = anchor_quote(near_miss, passages)
|
||||
assert near_verdict.matched is False or near_verdict.weak is True
|
||||
|
||||
exact = anchor_quote(passages[0].quote, passages)
|
||||
assert exact.matched is True
|
||||
|
||||
|
||||
def test_attack_quote_from_other_passage_counts_as_corroboration_not_anchor() -> None:
|
||||
from researchx.adapters.fakes import SHARED_SENTENCE
|
||||
|
||||
store = EvidenceStore()
|
||||
first = store.add_passage("https://a.example.org", SHARED_SENTENCE)
|
||||
second = store.add_passage("https://b.example.org", SHARED_SENTENCE + " Extra tail text.")
|
||||
verdict_first = anchor_quote(second.quote, [first])
|
||||
assert verdict_first.matched is True or verdict_first.ratio > 0.5
|
||||
|
||||
|
||||
async def test_attack_liveness_private_redirect_dies(monkeypatch) -> None:
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
return httpx.Response(302, headers={"location": "http://169.254.169.254/x"})
|
||||
|
||||
transport = httpx.MockTransport(handler)
|
||||
async with httpx.AsyncClient(transport=transport) as client:
|
||||
status = await check_liveness("https://example.org/redirect", client)
|
||||
assert status == LivenessStatus.DEAD
|
||||
|
||||
|
||||
async def test_attack_liveness_redirect_chain_to_dead(monkeypatch) -> None:
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
if "hop1" in str(request.url):
|
||||
return httpx.Response(302, headers={"location": "https://example.org/hop2"})
|
||||
if "hop2" in str(request.url):
|
||||
return httpx.Response(302, headers={"location": "https://example.org/hop3"})
|
||||
return httpx.Response(410)
|
||||
|
||||
transport = httpx.MockTransport(handler)
|
||||
async with httpx.AsyncClient(transport=transport) as client:
|
||||
status = await check_liveness("https://example.org/hop1", client)
|
||||
assert status == LivenessStatus.DEAD
|
||||
|
||||
|
||||
async def test_attack_oversized_response_capped(monkeypatch) -> None:
|
||||
huge = "x" * (5 * 1024 * 1024)
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
return httpx.Response(200, text=huge)
|
||||
|
||||
transport = httpx.MockTransport(handler)
|
||||
async with httpx.AsyncClient(transport=transport) as client:
|
||||
status = await check_liveness("https://example.org/huge", client)
|
||||
assert status == LivenessStatus.LIVE
|
||||
|
||||
|
||||
async def test_attack_pause_resume_mid_depth_loop_over_http(tmp_path: Path) -> None:
|
||||
plan_template = json.dumps(
|
||||
{
|
||||
"sub_questions": ["verification passes", "quote anchoring"],
|
||||
"source_universe": ["web"],
|
||||
"outline": ["intro", "findings"],
|
||||
}
|
||||
)
|
||||
writer = "# Report\n\nClaim [p-1] and claim [p-2].\n"
|
||||
canned = [
|
||||
ChatResult(content=plan_template, model="fake-planner"),
|
||||
ChatResult(content=plan_template, model="fake-planner"),
|
||||
ChatResult(content=plan_template, model="fake-planner"),
|
||||
ChatResult(content=writer, model="fake-writer"),
|
||||
]
|
||||
app = create_app(
|
||||
storage_dir=tmp_path,
|
||||
model_adapter=FakeModelAdapter(canned=canned),
|
||||
search_adapter=FakeSearchAdapter(CORPUS),
|
||||
auth_enabled=False,
|
||||
data_dir=tmp_path,
|
||||
)
|
||||
async with httpx.AsyncClient(
|
||||
transport=httpx.ASGITransport(app=app), base_url="http://testserver"
|
||||
) as client:
|
||||
created = await client.post(
|
||||
"/api/runs",
|
||||
json={"question": "q", "methodology": "deep-report"},
|
||||
)
|
||||
assert created.status_code == 201
|
||||
run_id = created.json()["id"]
|
||||
|
||||
for _ in range(300):
|
||||
status = (await client.get(f"/api/runs/{run_id}")).json()["status"]
|
||||
if status == "awaiting_approval":
|
||||
break
|
||||
await asyncio.sleep(0.01)
|
||||
assert status == "awaiting_approval"
|
||||
|
||||
await client.post(f"/api/runs/{run_id}/approve")
|
||||
|
||||
paused_seen = False
|
||||
for _ in range(600):
|
||||
status = (await client.get(f"/api/runs/{run_id}")).json()["status"]
|
||||
if status in ("reflecting", "searching", "extracting"):
|
||||
await client.post(f"/api/runs/{run_id}/pause")
|
||||
await client.post(f"/api/runs/{run_id}/resume")
|
||||
paused_seen = True
|
||||
break
|
||||
if status == "done":
|
||||
break
|
||||
await asyncio.sleep(0.005)
|
||||
|
||||
for _ in range(600):
|
||||
status = (await client.get(f"/api/runs/{run_id}")).json()["status"]
|
||||
if status == "done":
|
||||
break
|
||||
await asyncio.sleep(0.005)
|
||||
assert status == "done"
|
||||
assert paused_seen, "mid-loop pause/resume must have been exercised"
|
||||
|
||||
report = await client.get(f"/api/runs/{run_id}/report")
|
||||
assert report.status_code == 200
|
||||
|
||||
|
||||
async def test_attack_cancel_mid_loop_persists(tmp_path: Path) -> None:
|
||||
plan = json.dumps(
|
||||
{
|
||||
"sub_questions": ["verification passes", "quote anchoring"],
|
||||
"source_universe": ["web"],
|
||||
"outline": ["intro"],
|
||||
}
|
||||
)
|
||||
app = create_app(
|
||||
storage_dir=tmp_path,
|
||||
model_adapter=FakeModelAdapter(
|
||||
canned=[ChatResult(content=plan, model="fake-planner") for _ in range(10)]
|
||||
+ [ChatResult(content="# never reached", model="fake-writer")]
|
||||
),
|
||||
search_adapter=FakeSearchAdapter(CORPUS),
|
||||
auth_enabled=False,
|
||||
data_dir=tmp_path,
|
||||
)
|
||||
async with httpx.AsyncClient(
|
||||
transport=httpx.ASGITransport(app=app), base_url="http://testserver"
|
||||
) as client:
|
||||
created = await client.post(
|
||||
"/api/runs", json={"question": "q", "methodology": "deep-report"}
|
||||
)
|
||||
run_id = created.json()["id"]
|
||||
for _ in range(300):
|
||||
if (await client.get(f"/api/runs/{run_id}")).json()["status"] == "awaiting_approval":
|
||||
break
|
||||
await asyncio.sleep(0.01)
|
||||
await client.post(f"/api/runs/{run_id}/approve")
|
||||
for _ in range(600):
|
||||
status = (await client.get(f"/api/runs/{run_id}")).json()["status"]
|
||||
if status in ("searching", "extracting", "reflecting"):
|
||||
await client.post(f"/api/runs/{run_id}/cancel")
|
||||
break
|
||||
await asyncio.sleep(0.005)
|
||||
for _ in range(300):
|
||||
status = (await client.get(f"/api/runs/{run_id}")).json()["status"]
|
||||
if status == "cancelled":
|
||||
break
|
||||
await asyncio.sleep(0.01)
|
||||
assert status == "cancelled"
|
||||
|
||||
def test_attack_weak_band_pinned_real_input() -> None:
|
||||
store = EvidenceStore()
|
||||
passage_text = (
|
||||
"The verification pipeline re-anchored every quoted claim to its stored "
|
||||
"passage before the report was allowed to ship to the reading room."
|
||||
)
|
||||
passage = store.add_passage("https://example.org/weak", passage_text)
|
||||
words = passage_text.split()
|
||||
near_miss = " ".join(words[:11]) + " released to the audience."
|
||||
verdict = anchor_quote(near_miss, [passage])
|
||||
assert 0.80 <= verdict.ratio < 0.85, f"expected weak band, got {verdict.ratio}"
|
||||
assert verdict.matched is False
|
||||
assert verdict.weak is True, f"near-threshold must flag weak, got {verdict.ratio}"
|
||||
|
||||
|
||||
def test_attack_fully_fabricated_report_fails_export() -> None:
|
||||
store = EvidenceStore()
|
||||
real = store.add_passage("https://example.org/real", "A genuine stored passage " * 10)
|
||||
fabricated_report = (
|
||||
"# Report\n\nClaim [p-1].\n\nClaim [p-7]. Claim [p-42].\n"
|
||||
)
|
||||
ledger = build_ledger(
|
||||
run_id="attack002",
|
||||
report_text=fabricated_report,
|
||||
evidence=store,
|
||||
liveness={"https://example.org/real": LivenessStatus.LIVE},
|
||||
visited_at=datetime.now(UTC),
|
||||
)
|
||||
passed, failures = liveness_export_gate(ledger)
|
||||
assert passed is False, "fully-fabricated report must fail the export gate"
|
||||
assert failures == []
|
||||
assert ledger.checklist.unanchored_claims >= 2
|
||||
del real
|
||||
|
||||
|
||||
def test_export_round_trip_json_validates_into_backend_model() -> None:
|
||||
from researchx.trust.ledger import CitationLedger
|
||||
|
||||
store = EvidenceStore()
|
||||
stored_text = "A passage with real content for the round-trip proof."
|
||||
store.add_passage("https://example.org/a", stored_text)
|
||||
report = f"# Report\n\n{stored_text} [p-1].\n"
|
||||
ledger = build_ledger(
|
||||
run_id="attack003",
|
||||
report_text=report,
|
||||
evidence=store,
|
||||
liveness={"https://example.org/a": LivenessStatus.LIVE},
|
||||
visited_at=datetime.now(UTC),
|
||||
)
|
||||
exported = ledger.model_dump_json()
|
||||
restored = CitationLedger.model_validate_json(exported)
|
||||
assert restored == ledger
|
||||
checklist_md = export_checklist(ledger)
|
||||
assert "Anchoring gate" in checklist_md
|
||||
assert "PASS" in checklist_md
|
||||
|
||||
|
||||
def test_attack_fabricated_quote_on_real_passage_id_fails() -> None:
|
||||
store = EvidenceStore()
|
||||
stored = store.add_passage(
|
||||
"https://example.org/real",
|
||||
"The genuine finding about verification pipelines is that anchoring works. " * 3,
|
||||
)
|
||||
report = (
|
||||
"# Report\n\n"
|
||||
f"THIS FABRICATED QUOTE WAS NEVER STORED ANYWHERE AT ALL [{stored.passage_id}]\n"
|
||||
)
|
||||
ledger = build_ledger(
|
||||
run_id="attack004",
|
||||
report_text=report,
|
||||
evidence=store,
|
||||
liveness={"https://example.org/real": LivenessStatus.LIVE},
|
||||
visited_at=datetime.now(UTC),
|
||||
)
|
||||
assert ledger.checklist.unanchored_claims >= 1, (
|
||||
"a fabricated quote riding a real passage ID must be flagged (REQ-20)"
|
||||
)
|
||||
passed, _ = liveness_export_gate(ledger)
|
||||
assert passed is False
|
||||
|
||||
|
||||
def test_attack_real_verbatim_quote_on_real_passage_anchors() -> None:
|
||||
store = EvidenceStore()
|
||||
stored_text = (
|
||||
"The genuine finding about verification pipelines is that anchoring works."
|
||||
)
|
||||
stored = store.add_passage("https://example.org/real", stored_text + " " + stored_text)
|
||||
report = f"# Report\n\n{stored_text} [{stored.passage_id}]\n"
|
||||
ledger = build_ledger(
|
||||
run_id="attack005",
|
||||
report_text=report,
|
||||
evidence=store,
|
||||
liveness={"https://example.org/real": LivenessStatus.LIVE},
|
||||
visited_at=datetime.now(UTC),
|
||||
)
|
||||
assert ledger.checklist.anchored_claims == 1
|
||||
assert ledger.checklist.unanchored_claims == 0
|
||||
passed, _ = liveness_export_gate(ledger)
|
||||
assert passed is True
|
||||
|
||||
|
||||
async def test_attack_search_result_redirecting_to_private_ip_refused(monkeypatch) -> None:
|
||||
import tempfile
|
||||
from pathlib import Path as PathMod
|
||||
|
||||
import httpx as httpx_mod
|
||||
|
||||
from researchx.adapters.secrets import EnvFileSecretsProvider
|
||||
from researchx.adapters.tavily import TavilySearchAdapter
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
env_file = PathMod(tmpdir) / ".env.secrets"
|
||||
env_file.write_text("TAVILY_API_KEY=t-key\n", encoding="utf-8")
|
||||
secrets = EnvFileSecretsProvider(paths=[env_file])
|
||||
adapter = TavilySearchAdapter(secrets)
|
||||
|
||||
def handler(request: httpx_mod.Request) -> httpx_mod.Response:
|
||||
if "boobytrap" in str(request.url):
|
||||
return httpx_mod.Response(
|
||||
302, headers={"location": "http://169.254.169.254/latest/meta-data"}
|
||||
)
|
||||
return httpx_mod.Response(200, text="<html><body>ok</body></html>")
|
||||
|
||||
transport = httpx_mod.MockTransport(handler)
|
||||
original = httpx_mod.AsyncClient
|
||||
monkeypatch.setattr(
|
||||
httpx_mod, "AsyncClient", lambda **kw: original(transport=transport, **kw)
|
||||
)
|
||||
|
||||
try:
|
||||
await adapter.fetch("https://example.org/boobytrap")
|
||||
except AdapterError as exc:
|
||||
assert "refused" in str(exc) or "not allowed" in str(exc)
|
||||
else:
|
||||
raise AssertionError("fetch of a private-redirecting URL must be refused")
|
||||
@@ -0,0 +1,160 @@
|
||||
import asyncio
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import httpx
|
||||
|
||||
from researchx.adapters.fakes import CORPUS, FakeModelAdapter, FakeSearchAdapter
|
||||
from researchx.adapters.types import ChatResult
|
||||
from researchx.api.app import create_app
|
||||
|
||||
_PLAN = json.dumps(
|
||||
{"sub_questions": ["verification passes"], "source_universe": ["web"], "outline": ["intro"]}
|
||||
)
|
||||
_WRITER = "# Report\n\nClaim [p-1] and claim [p-2].\n"
|
||||
|
||||
|
||||
def _app(tmp_path: Path):
|
||||
# Zero-key dev harness (C3-16): auth explicitly off — the synthetic
|
||||
# dev admin resolves for every request.
|
||||
return create_app(
|
||||
storage_dir=tmp_path,
|
||||
model_adapter=FakeModelAdapter(
|
||||
canned=[
|
||||
ChatResult(content=_PLAN, model="fake-planner"),
|
||||
ChatResult(content=_WRITER, model="fake-writer"),
|
||||
]
|
||||
),
|
||||
search_adapter=FakeSearchAdapter(CORPUS),
|
||||
auth_enabled=False,
|
||||
data_dir=tmp_path,
|
||||
)
|
||||
|
||||
|
||||
async def _client(tmp_path: Path) -> httpx.AsyncClient:
|
||||
return httpx.AsyncClient(
|
||||
transport=httpx.ASGITransport(app=_app(tmp_path)),
|
||||
base_url="http://testserver",
|
||||
)
|
||||
|
||||
|
||||
async def test_config_status(tmp_path: Path) -> None:
|
||||
async with await _client(tmp_path) as client:
|
||||
response = await client.get("/api/config")
|
||||
assert response.status_code == 200
|
||||
body = response.json()
|
||||
assert body["model_adapter"] == "FakeModelAdapter"
|
||||
assert body["cost_counters"]["runs_created"] == 0
|
||||
|
||||
|
||||
async def test_methodology_cards(tmp_path: Path) -> None:
|
||||
async with await _client(tmp_path) as client:
|
||||
response = await client.get("/api/methodologies")
|
||||
assert response.status_code == 200
|
||||
cards = response.json()
|
||||
slugs = [c["slug"] for c in cards]
|
||||
assert "rapid-answer" in slugs and "deep-report" in slugs
|
||||
for card in cards:
|
||||
assert card["eta_minutes"] > 0
|
||||
assert "estimate" in card["cost_estimate"]
|
||||
assert card["why_recommended"]
|
||||
assert card["dials"]["depth"] >= 1
|
||||
|
||||
|
||||
async def test_create_run_rejects_unknown_methodology(tmp_path: Path) -> None:
|
||||
async with await _client(tmp_path) as client:
|
||||
response = await client.post(
|
||||
"/api/runs", json={"question": "q", "methodology": "nonexistent"}
|
||||
)
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
async def _await_status(client: httpx.AsyncClient, run_id: str, status: str) -> None:
|
||||
for _ in range(200):
|
||||
current = (await client.get(f"/api/runs/{run_id}")).json()
|
||||
if current["status"] == status:
|
||||
return
|
||||
await asyncio.sleep(0.01)
|
||||
raise AssertionError(f"run never reached {status}: {current['status']}")
|
||||
|
||||
|
||||
async def test_run_lifecycle_over_http(tmp_path: Path) -> None:
|
||||
async with await _client(tmp_path) as client:
|
||||
created = await client.post(
|
||||
"/api/runs", json={"question": "How do engines run?", "methodology": "rapid-answer"}
|
||||
)
|
||||
assert created.status_code == 201
|
||||
run_id = created.json()["id"]
|
||||
|
||||
await _await_status(client, run_id, "awaiting_approval")
|
||||
approved = await client.post(f"/api/runs/{run_id}/approve")
|
||||
assert approved.status_code == 200
|
||||
|
||||
await _await_status(client, run_id, "done")
|
||||
report = await client.get(f"/api/runs/{run_id}/report")
|
||||
assert report.status_code == 200
|
||||
assert "[p-1]" in report.json()["markdown"]
|
||||
|
||||
ledger = await client.get(f"/api/runs/{run_id}/ledger")
|
||||
assert ledger.status_code == 200
|
||||
assert ledger.json()["checklist"]["citations_checked"] >= 1
|
||||
|
||||
config = (await client.get("/api/config")).json()
|
||||
assert config["cost_counters"]["runs_created"] == 1
|
||||
assert config["cost_counters"]["runs_completed"] == 1
|
||||
|
||||
|
||||
async def test_missing_run_404(tmp_path: Path) -> None:
|
||||
async with await _client(tmp_path) as client:
|
||||
assert (await client.get("/api/runs/zzzz9999")).status_code == 404
|
||||
assert (await client.post("/api/runs/zzzz9999/approve")).status_code == 404
|
||||
assert (await client.get("/api/runs/zzzz9999/events")).status_code == 404
|
||||
|
||||
|
||||
async def test_sse_stream_replays_events(tmp_path: Path) -> None:
|
||||
client = await _client(tmp_path)
|
||||
try:
|
||||
created = await client.post(
|
||||
"/api/runs", json={"question": "q", "methodology": "rapid-answer"}
|
||||
)
|
||||
run_id = created.json()["id"]
|
||||
await _await_status(client, run_id, "awaiting_approval")
|
||||
await client.post(f"/api/runs/{run_id}/approve")
|
||||
await _await_status(client, run_id, "done")
|
||||
|
||||
collected = b""
|
||||
async with client.stream("GET", f"/api/runs/{run_id}/events") as response:
|
||||
assert response.status_code == 200
|
||||
assert response.headers["content-type"].startswith("text/event-stream")
|
||||
async for chunk in response.aiter_bytes():
|
||||
collected += chunk
|
||||
if b"run_done" in collected:
|
||||
break
|
||||
text = collected.decode()
|
||||
assert "event: run_started" in text
|
||||
assert "event: run_done" in text
|
||||
finally:
|
||||
await client.aclose()
|
||||
|
||||
|
||||
async def test_pause_resume_cancel_over_http(tmp_path: Path) -> None:
|
||||
async with await _client(tmp_path) as client:
|
||||
created = await client.post(
|
||||
"/api/runs", json={"question": "q", "methodology": "rapid-answer"}
|
||||
)
|
||||
run_id = created.json()["id"]
|
||||
await _await_status(client, run_id, "awaiting_approval")
|
||||
|
||||
paused = await client.post(f"/api/runs/{run_id}/pause")
|
||||
assert paused.status_code == 200
|
||||
resumed = await client.post(f"/api/runs/{run_id}/resume")
|
||||
assert resumed.status_code == 200
|
||||
|
||||
cancelled = await client.post(f"/api/runs/{run_id}/cancel")
|
||||
assert cancelled.status_code == 200
|
||||
for _ in range(200):
|
||||
status = (await client.get(f"/api/runs/{run_id}")).json()["status"]
|
||||
if status == "cancelled":
|
||||
break
|
||||
await asyncio.sleep(0.01)
|
||||
assert status == "cancelled"
|
||||
@@ -0,0 +1,590 @@
|
||||
"""Auth route suite (T-1.9 backend half, REQ-69/70 — RESEARCH §11 list).
|
||||
|
||||
Full adversarial surface of the auth layer, run with
|
||||
``auth_enabled=True``: first-user-admin, registration closing, uniform
|
||||
login failures, session/logout semantics, CSRF enforcement, admin
|
||||
create/deactivate (deactivation kills sessions), and the
|
||||
no-password-hash-anywhere guarantee.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import httpx
|
||||
|
||||
from researchx.adapters.fakes import CORPUS, FakeModelAdapter, FakeSearchAdapter
|
||||
from researchx.adapters.types import ChatResult
|
||||
from researchx.api.app import create_app
|
||||
from researchx.auth.guard import CSRF_HEADER
|
||||
|
||||
_PLAN = json.dumps(
|
||||
{"sub_questions": ["verification passes"], "source_universe": ["web"], "outline": ["intro"]}
|
||||
)
|
||||
_WRITER = "# Report\n\nClaim [p-1] and claim [p-2].\n"
|
||||
|
||||
_ADMIN_EMAIL = "admin@example.org"
|
||||
_ADMIN_PASSWORD = "correct-horse-battery"
|
||||
_SECOND_EMAIL = "second@example.org"
|
||||
_SECOND_PASSWORD = "another-long-password"
|
||||
|
||||
_HEADERS = {CSRF_HEADER: "1"}
|
||||
|
||||
|
||||
def _app(tmp_path: Path):
|
||||
return create_app(
|
||||
storage_dir=tmp_path,
|
||||
model_adapter=FakeModelAdapter(
|
||||
canned=[
|
||||
ChatResult(content=_PLAN, model="fake-planner"),
|
||||
ChatResult(content=_WRITER, model="fake-writer"),
|
||||
]
|
||||
),
|
||||
search_adapter=FakeSearchAdapter(CORPUS),
|
||||
auth_enabled=True,
|
||||
data_dir=tmp_path,
|
||||
)
|
||||
|
||||
|
||||
async def _client(tmp_path: Path) -> httpx.AsyncClient:
|
||||
return httpx.AsyncClient(
|
||||
transport=httpx.ASGITransport(app=_app(tmp_path)),
|
||||
base_url="http://testserver",
|
||||
)
|
||||
|
||||
|
||||
async def _register_first_admin(client: httpx.AsyncClient) -> httpx.Response:
|
||||
return await client.post(
|
||||
"/api/auth/register",
|
||||
json={"email": _ADMIN_EMAIL, "password": _ADMIN_PASSWORD, "display_name": "Admin"},
|
||||
headers=_HEADERS,
|
||||
)
|
||||
|
||||
|
||||
async def _login(client: httpx.AsyncClient, email: str, password: str) -> httpx.Response:
|
||||
return await client.post(
|
||||
"/api/auth/login", json={"email": email, "password": password}, headers=_HEADERS
|
||||
)
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ register
|
||||
|
||||
|
||||
async def test_healthz_outside_api_is_public(tmp_path: Path) -> None:
|
||||
async with await _client(tmp_path) as client:
|
||||
response = await client.get("/healthz")
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"status": "ok"}
|
||||
|
||||
|
||||
async def test_first_register_creates_admin(tmp_path: Path) -> None:
|
||||
async with await _client(tmp_path) as client:
|
||||
response = await _register_first_admin(client)
|
||||
assert response.status_code == 201
|
||||
body = response.json()
|
||||
assert body["role"] == "admin"
|
||||
assert body["email"] == _ADMIN_EMAIL
|
||||
assert body["active"] is True
|
||||
assert len(body["user_id"]) == 8
|
||||
assert "password_hash" not in body
|
||||
assert "password" not in body
|
||||
|
||||
|
||||
async def test_second_register_is_403(tmp_path: Path) -> None:
|
||||
async with await _client(tmp_path) as client:
|
||||
await _register_first_admin(client)
|
||||
response = await client.post(
|
||||
"/api/auth/register",
|
||||
json={"email": _SECOND_EMAIL, "password": _SECOND_PASSWORD},
|
||||
headers=_HEADERS,
|
||||
)
|
||||
assert response.status_code == 403
|
||||
assert response.json()["detail"] == "registration closed"
|
||||
|
||||
|
||||
async def test_register_rejects_short_password(tmp_path: Path) -> None:
|
||||
async with await _client(tmp_path) as client:
|
||||
response = await client.post(
|
||||
"/api/auth/register",
|
||||
json={"email": _ADMIN_EMAIL, "password": "short"},
|
||||
headers=_HEADERS,
|
||||
)
|
||||
assert response.status_code == 422 # pydantic contract rejects < 12
|
||||
|
||||
|
||||
async def test_register_requires_csrf_header(tmp_path: Path) -> None:
|
||||
async with await _client(tmp_path) as client:
|
||||
response = await client.post(
|
||||
"/api/auth/register",
|
||||
json={"email": _ADMIN_EMAIL, "password": _ADMIN_PASSWORD},
|
||||
)
|
||||
assert response.status_code == 403
|
||||
assert response.json()["detail"] == "missing request header"
|
||||
|
||||
|
||||
async def test_concurrent_register_yields_exactly_one_admin(tmp_path: Path) -> None:
|
||||
"""D-104 adversarial pin: a simultaneous register storm on a fresh
|
||||
store must produce exactly one 201/admin — the emptiness check +
|
||||
insert are atomic under the store lock (create_first_admin), so a
|
||||
raced duplicate cannot both win. Regression guard for the
|
||||
create_user-based route that let N concurrent registers through."""
|
||||
racers = [(f"racer{i}@example.org", f"racing-password-{i:02d}") for i in range(8)]
|
||||
async with await _client(tmp_path) as client:
|
||||
responses = await asyncio.gather(
|
||||
*(
|
||||
client.post(
|
||||
"/api/auth/register",
|
||||
json={"email": email, "password": password},
|
||||
headers=_HEADERS,
|
||||
)
|
||||
for email, password in racers
|
||||
)
|
||||
)
|
||||
winners = [(r, racer) for r, racer in zip(responses, racers, strict=True) if r.status_code == 201]
|
||||
losers = [r for r in responses if r.status_code == 403]
|
||||
assert len(winners) == 1, "exactly one register may win the race"
|
||||
assert len(losers) == 7
|
||||
assert winners[0][0].json()["role"] == "admin"
|
||||
|
||||
# The winner can log in; every losing racer has no account.
|
||||
_, (winner_email, winner_password) = winners[0]
|
||||
login = await client.post(
|
||||
"/api/auth/login",
|
||||
json={"email": winner_email, "password": winner_password},
|
||||
headers=_HEADERS,
|
||||
)
|
||||
assert login.status_code == 200
|
||||
for email, password in racers:
|
||||
if email == winner_email:
|
||||
continue
|
||||
denied = await client.post(
|
||||
"/api/auth/login",
|
||||
json={"email": email, "password": password},
|
||||
headers=_HEADERS,
|
||||
)
|
||||
assert denied.status_code == 401, "losing racer must have no account"
|
||||
|
||||
|
||||
# -------------------------------------------------------------------- login
|
||||
|
||||
|
||||
async def test_login_sets_httponly_cookie(tmp_path: Path) -> None:
|
||||
async with await _client(tmp_path) as client:
|
||||
await _register_first_admin(client)
|
||||
response = await _login(client, _ADMIN_EMAIL, _ADMIN_PASSWORD)
|
||||
assert response.status_code == 200
|
||||
assert response.json()["role"] == "admin"
|
||||
set_cookie = response.headers["set-cookie"]
|
||||
assert "researchx_session=" in set_cookie
|
||||
assert "httponly" in set_cookie.lower()
|
||||
assert "samesite=lax" in set_cookie.lower()
|
||||
assert "path=/" in set_cookie.lower()
|
||||
assert "domain=" not in set_cookie.lower().replace("samesite=lax", "")
|
||||
assert "secure" not in set_cookie.lower() # cookie_secure=False in tests
|
||||
|
||||
|
||||
async def test_login_wrong_password_uniform_401(tmp_path: Path) -> None:
|
||||
async with await _client(tmp_path) as client:
|
||||
await _register_first_admin(client)
|
||||
for email, password in [
|
||||
(_ADMIN_EMAIL, "wrong-password-here"),
|
||||
("ghost@example.org", _ADMIN_PASSWORD),
|
||||
]:
|
||||
response = await _login(client, email, password)
|
||||
assert response.status_code == 401
|
||||
assert response.json()["detail"] == "invalid email or password"
|
||||
|
||||
|
||||
async def test_login_token_rotation(tmp_path: Path) -> None:
|
||||
async with await _client(tmp_path) as client:
|
||||
await _register_first_admin(client)
|
||||
first = await _login(client, _ADMIN_EMAIL, _ADMIN_PASSWORD)
|
||||
first_cookie = first.headers["set-cookie"].split(";")[0]
|
||||
second = await _login(client, _ADMIN_EMAIL, _ADMIN_PASSWORD)
|
||||
second_cookie = second.headers["set-cookie"].split(";")[0]
|
||||
assert first_cookie != second_cookie # fresh token every login
|
||||
|
||||
|
||||
# ----------------------------------------------------------- guard + CSRF
|
||||
|
||||
|
||||
async def test_unauthenticated_401_on_protected_routes(tmp_path: Path) -> None:
|
||||
async with await _client(tmp_path) as client:
|
||||
for method, path in [
|
||||
("GET", "/api/methodologies"),
|
||||
("GET", "/api/config"),
|
||||
("GET", "/api/runs/abcd2345"),
|
||||
("GET", "/api/runs/abcd2345/events"),
|
||||
("GET", "/api/runs/abcd2345/report"),
|
||||
("GET", "/api/admin/users"),
|
||||
]:
|
||||
response = await client.request(method, path)
|
||||
assert response.status_code == 401, f"{method} {path} leaked without auth"
|
||||
assert response.json()["detail"] == "authentication required"
|
||||
|
||||
|
||||
async def test_csrf_header_required_on_non_get(tmp_path: Path) -> None:
|
||||
async with await _client(tmp_path) as client:
|
||||
await _register_first_admin(client)
|
||||
await _login(client, _ADMIN_EMAIL, _ADMIN_PASSWORD)
|
||||
# Session cookie attached, but no CSRF header on a POST.
|
||||
response = await client.post(
|
||||
"/api/runs", json={"question": "q", "methodology": "rapid-answer"}
|
||||
)
|
||||
assert response.status_code == 403
|
||||
assert response.json()["detail"] == "missing request header"
|
||||
|
||||
|
||||
async def test_gets_exempt_from_csrf_header(tmp_path: Path) -> None:
|
||||
async with await _client(tmp_path) as client:
|
||||
await _register_first_admin(client)
|
||||
await _login(client, _ADMIN_EMAIL, _ADMIN_PASSWORD)
|
||||
assert (await client.get("/api/methodologies")).status_code == 200
|
||||
assert (await client.get("/api/auth/me")).status_code == 200
|
||||
|
||||
|
||||
# ----------------------------------------------------------- me + logout
|
||||
|
||||
|
||||
async def test_me_returns_current_user(tmp_path: Path) -> None:
|
||||
async with await _client(tmp_path) as client:
|
||||
await _register_first_admin(client)
|
||||
await _login(client, _ADMIN_EMAIL, _ADMIN_PASSWORD)
|
||||
response = await client.get("/api/auth/me")
|
||||
assert response.status_code == 200
|
||||
body = response.json()
|
||||
assert body["email"] == _ADMIN_EMAIL
|
||||
assert "password_hash" not in body
|
||||
|
||||
|
||||
async def test_logout_revokes_session(tmp_path: Path) -> None:
|
||||
async with await _client(tmp_path) as client:
|
||||
await _register_first_admin(client)
|
||||
await _login(client, _ADMIN_EMAIL, _ADMIN_PASSWORD)
|
||||
assert (await client.get("/api/methodologies")).status_code == 200
|
||||
response = await client.post("/api/auth/logout", headers=_HEADERS)
|
||||
assert response.status_code == 200
|
||||
assert (await client.get("/api/methodologies")).status_code == 401
|
||||
|
||||
|
||||
async def test_deactivated_user_401(tmp_path: Path) -> None:
|
||||
app = _app(tmp_path)
|
||||
transport = httpx.ASGITransport(app=app)
|
||||
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://testserver") as admin:
|
||||
await _register_first_admin(admin)
|
||||
await _login(admin, _ADMIN_EMAIL, _ADMIN_PASSWORD)
|
||||
created = await admin.post(
|
||||
"/api/admin/users",
|
||||
json={"email": _SECOND_EMAIL, "password": _SECOND_PASSWORD, "role": "user"},
|
||||
headers=_HEADERS,
|
||||
)
|
||||
assert created.status_code == 201
|
||||
second_id = created.json()["user_id"]
|
||||
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://testserver") as second:
|
||||
await second.post(
|
||||
"/api/auth/login",
|
||||
json={"email": _SECOND_EMAIL, "password": _SECOND_PASSWORD},
|
||||
headers=_HEADERS,
|
||||
)
|
||||
assert (await second.get("/api/methodologies")).status_code == 200
|
||||
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://testserver") as admin:
|
||||
await _login(admin, _ADMIN_EMAIL, _ADMIN_PASSWORD)
|
||||
deactivated = await admin.post(
|
||||
f"/api/admin/users/{second_id}/deactivate", headers=_HEADERS
|
||||
)
|
||||
assert deactivated.status_code == 200
|
||||
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://testserver") as second:
|
||||
# The deactivated user's session was revoked server-side.
|
||||
response = await second.get("/api/methodologies")
|
||||
assert response.status_code == 401
|
||||
|
||||
# A fresh login against the deactivated account also 401s —
|
||||
# uniformly, without revealing the reason (no enumeration).
|
||||
login = await second.post(
|
||||
"/api/auth/login",
|
||||
json={"email": _SECOND_EMAIL, "password": _SECOND_PASSWORD},
|
||||
headers=_HEADERS,
|
||||
)
|
||||
assert login.status_code == 401
|
||||
assert login.json()["detail"] == "invalid email or password"
|
||||
|
||||
|
||||
# ------------------------------------------------------------------- admin
|
||||
|
||||
|
||||
async def test_admin_list_users_no_hashes(tmp_path: Path) -> None:
|
||||
async with await _client(tmp_path) as client:
|
||||
await _register_first_admin(client)
|
||||
await _login(client, _ADMIN_EMAIL, _ADMIN_PASSWORD)
|
||||
response = await client.get("/api/admin/users")
|
||||
assert response.status_code == 200
|
||||
body = response.json()
|
||||
assert len(body) == 1
|
||||
raw = json.dumps(body)
|
||||
assert "password_hash" not in raw
|
||||
assert _ADMIN_PASSWORD not in raw
|
||||
assert "argon2" not in raw
|
||||
|
||||
|
||||
async def test_admin_create_user_then_user_login(tmp_path: Path) -> None:
|
||||
async with await _client(tmp_path) as client:
|
||||
await _register_first_admin(client)
|
||||
await _login(client, _ADMIN_EMAIL, _ADMIN_PASSWORD)
|
||||
created = await client.post(
|
||||
"/api/admin/users",
|
||||
json={"email": _SECOND_EMAIL, "password": _SECOND_PASSWORD, "role": "user"},
|
||||
headers=_HEADERS,
|
||||
)
|
||||
assert created.status_code == 201
|
||||
body = created.json()
|
||||
assert body["role"] == "user"
|
||||
assert body["active"] is True
|
||||
|
||||
login = await _login(client, _SECOND_EMAIL, _SECOND_PASSWORD)
|
||||
assert login.status_code == 200
|
||||
assert login.json()["role"] == "user"
|
||||
|
||||
|
||||
async def test_admin_create_rejects_short_password(tmp_path: Path) -> None:
|
||||
async with await _client(tmp_path) as client:
|
||||
await _register_first_admin(client)
|
||||
await _login(client, _ADMIN_EMAIL, _ADMIN_PASSWORD)
|
||||
response = await client.post(
|
||||
"/api/admin/users",
|
||||
json={"email": _SECOND_EMAIL, "password": "short"},
|
||||
headers=_HEADERS,
|
||||
)
|
||||
assert response.status_code == 422
|
||||
|
||||
|
||||
async def test_admin_deactivate_revokes_sessions(tmp_path: Path) -> None:
|
||||
app = _app(tmp_path)
|
||||
async with httpx.AsyncClient(
|
||||
transport=httpx.ASGITransport(app=app), base_url="http://testserver"
|
||||
) as admin:
|
||||
await _register_first_admin(admin)
|
||||
await _login(admin, _ADMIN_EMAIL, _ADMIN_PASSWORD)
|
||||
created = await admin.post(
|
||||
"/api/admin/users",
|
||||
json={"email": _SECOND_EMAIL, "password": _SECOND_PASSWORD, "role": "user"},
|
||||
headers=_HEADERS,
|
||||
)
|
||||
second_id = created.json()["user_id"]
|
||||
|
||||
# A separate client (separate cookie jar) for the second user.
|
||||
async with httpx.AsyncClient(
|
||||
transport=httpx.ASGITransport(app=app), base_url="http://testserver"
|
||||
) as second:
|
||||
await second.post(
|
||||
"/api/auth/login",
|
||||
json={"email": _SECOND_EMAIL, "password": _SECOND_PASSWORD},
|
||||
headers=_HEADERS,
|
||||
)
|
||||
assert (await second.get("/api/methodologies")).status_code == 200
|
||||
|
||||
# Deactivation from the admin session kills the second session.
|
||||
async with httpx.AsyncClient(
|
||||
transport=httpx.ASGITransport(app=app), base_url="http://testserver"
|
||||
) as admin:
|
||||
await _register_first_admin(admin) # store shared; already admin
|
||||
await _login(admin, _ADMIN_EMAIL, _ADMIN_PASSWORD)
|
||||
deactivated = await admin.post(
|
||||
f"/api/admin/users/{second_id}/deactivate", headers=_HEADERS
|
||||
)
|
||||
assert deactivated.status_code == 200
|
||||
assert deactivated.json()["active"] is False
|
||||
|
||||
response = await second.get("/api/methodologies")
|
||||
assert response.status_code == 401 # session revoked by deactivation
|
||||
|
||||
|
||||
async def test_non_admin_cannot_reach_admin_routes(tmp_path: Path) -> None:
|
||||
async with await _client(tmp_path) as client:
|
||||
await _register_first_admin(client)
|
||||
await _login(client, _ADMIN_EMAIL, _ADMIN_PASSWORD)
|
||||
await client.post(
|
||||
"/api/admin/users",
|
||||
json={"email": _SECOND_EMAIL, "password": _SECOND_PASSWORD, "role": "user"},
|
||||
headers=_HEADERS,
|
||||
)
|
||||
await _login(client, _SECOND_EMAIL, _SECOND_PASSWORD)
|
||||
response = await client.get("/api/admin/users")
|
||||
assert response.status_code == 403
|
||||
assert response.json()["detail"] == "admin role required"
|
||||
|
||||
|
||||
async def test_admin_deactivate_unknown_user_404(tmp_path: Path) -> None:
|
||||
async with await _client(tmp_path) as client:
|
||||
await _register_first_admin(client)
|
||||
await _login(client, _ADMIN_EMAIL, _ADMIN_PASSWORD)
|
||||
response = await client.post("/api/admin/users/zzzz9999/deactivate", headers=_HEADERS)
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
# ------------------------------------------------------ authenticated runs
|
||||
|
||||
|
||||
async def test_cross_owner_access_404_on_every_run_endpoint(tmp_path: Path) -> None:
|
||||
"""Two-user isolation (REQ-71): zero visibility across every run
|
||||
endpoint — cross-owner access is indistinguishable from a missing
|
||||
run (404, not 403: no existence leak)."""
|
||||
app = _app(tmp_path)
|
||||
transport = httpx.ASGITransport(app=app)
|
||||
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://testserver") as admin:
|
||||
await _register_first_admin(admin)
|
||||
await _login(admin, _ADMIN_EMAIL, _ADMIN_PASSWORD)
|
||||
created = await admin.post(
|
||||
"/api/runs",
|
||||
json={"question": "admin's private question", "methodology": "rapid-answer"},
|
||||
headers=_HEADERS,
|
||||
)
|
||||
assert created.status_code == 201
|
||||
run_id = created.json()["id"]
|
||||
|
||||
await admin.post(
|
||||
"/api/admin/users",
|
||||
json={"email": _SECOND_EMAIL, "password": _SECOND_PASSWORD, "role": "user"},
|
||||
headers=_HEADERS,
|
||||
)
|
||||
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://testserver") as second:
|
||||
login = await _login(second, _SECOND_EMAIL, _SECOND_PASSWORD)
|
||||
assert login.status_code == 200
|
||||
del login # ids don't matter here; counters do
|
||||
|
||||
for method, path in [
|
||||
("GET", f"/api/runs/{run_id}"),
|
||||
("POST", f"/api/runs/{run_id}/approve"),
|
||||
("POST", f"/api/runs/{run_id}/pause"),
|
||||
("POST", f"/api/runs/{run_id}/resume"),
|
||||
("POST", f"/api/runs/{run_id}/cancel"),
|
||||
("GET", f"/api/runs/{run_id}/events"),
|
||||
("GET", f"/api/runs/{run_id}/report"),
|
||||
("GET", f"/api/runs/{run_id}/ledger"),
|
||||
]:
|
||||
response = await second.request(method, path, headers=_HEADERS)
|
||||
assert response.status_code == 404, f"{method} {path} leaked across owners"
|
||||
|
||||
# The owner still sees their run — the 404s were authorization, not
|
||||
# the run vanishing.
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://testserver") as admin:
|
||||
await _login(admin, _ADMIN_EMAIL, _ADMIN_PASSWORD)
|
||||
response = await admin.get(f"/api/runs/{run_id}")
|
||||
assert response.status_code == 200
|
||||
|
||||
|
||||
async def test_owner_id_recorded_in_persisted_state(tmp_path: Path) -> None:
|
||||
async with await _client(tmp_path) as client:
|
||||
await _register_first_admin(client)
|
||||
login = await _login(client, _ADMIN_EMAIL, _ADMIN_PASSWORD)
|
||||
user_id = login.json()["user_id"]
|
||||
created = await client.post(
|
||||
"/api/runs",
|
||||
json={"question": "q", "methodology": "rapid-answer"},
|
||||
headers=_HEADERS,
|
||||
)
|
||||
run_id = created.json()["id"]
|
||||
|
||||
state_path = tmp_path / "runs" / user_id / run_id / "state.json"
|
||||
for _ in range(200): # the actor persists asynchronously
|
||||
if state_path.exists():
|
||||
break
|
||||
await asyncio.sleep(0.01)
|
||||
assert state_path.exists(), "storage layout must be runs/<user_id>/<run_id>/"
|
||||
state_text = state_path.read_text()
|
||||
state = json.loads(state_text)
|
||||
assert state["owner_id"] == user_id
|
||||
|
||||
|
||||
async def test_cost_counters_owner_keyed(tmp_path: Path) -> None:
|
||||
app = _app(tmp_path)
|
||||
transport = httpx.ASGITransport(app=app)
|
||||
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://testserver") as admin:
|
||||
await _register_first_admin(admin)
|
||||
await _login(admin, _ADMIN_EMAIL, _ADMIN_PASSWORD)
|
||||
created = await admin.post(
|
||||
"/api/runs",
|
||||
json={"question": "q", "methodology": "rapid-answer"},
|
||||
headers=_HEADERS,
|
||||
)
|
||||
assert created.status_code == 201
|
||||
config = await admin.get("/api/config")
|
||||
counters = config.json()["cost_counters"]
|
||||
assert counters["runs_created"] == 1 # the admin's own view, owner-keyed
|
||||
|
||||
await admin.post(
|
||||
"/api/admin/users",
|
||||
json={"email": _SECOND_EMAIL, "password": _SECOND_PASSWORD, "role": "user"},
|
||||
headers=_HEADERS,
|
||||
)
|
||||
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://testserver") as second:
|
||||
await _login(second, _SECOND_EMAIL, _SECOND_PASSWORD)
|
||||
created = await second.post(
|
||||
"/api/runs",
|
||||
json={"question": "q2", "methodology": "rapid-answer"},
|
||||
headers=_HEADERS,
|
||||
)
|
||||
assert created.status_code == 201
|
||||
config = await second.get("/api/config")
|
||||
assert config.json()["cost_counters"] == {"runs_created": 1, "runs_completed": 0}
|
||||
|
||||
|
||||
async def test_authenticated_run_lifecycle(tmp_path: Path) -> None:
|
||||
async with await _client(tmp_path) as client:
|
||||
await _register_first_admin(client)
|
||||
await _login(client, _ADMIN_EMAIL, _ADMIN_PASSWORD)
|
||||
created = await client.post(
|
||||
"/api/runs",
|
||||
json={"question": "How do engines run?", "methodology": "rapid-answer"},
|
||||
headers=_HEADERS,
|
||||
)
|
||||
assert created.status_code == 201
|
||||
run_id = created.json()["id"]
|
||||
|
||||
for _ in range(200):
|
||||
status = (await client.get(f"/api/runs/{run_id}")).json()["status"]
|
||||
if status == "awaiting_approval":
|
||||
break
|
||||
await asyncio.sleep(0.01)
|
||||
approved = await client.post(f"/api/runs/{run_id}/approve", headers=_HEADERS)
|
||||
assert approved.status_code == 200
|
||||
for _ in range(200):
|
||||
status = (await client.get(f"/api/runs/{run_id}")).json()["status"]
|
||||
if status == "done":
|
||||
break
|
||||
await asyncio.sleep(0.01)
|
||||
report = await client.get(f"/api/runs/{run_id}/report")
|
||||
assert report.status_code == 200
|
||||
assert "[p-1]" in report.json()["markdown"]
|
||||
|
||||
|
||||
async def test_no_password_hash_in_any_response(tmp_path: Path) -> None:
|
||||
"""Every response body of a full auth + run session, scanned."""
|
||||
async with await _client(tmp_path) as client:
|
||||
await _register_first_admin(client)
|
||||
await _login(client, _ADMIN_EMAIL, _ADMIN_PASSWORD)
|
||||
await client.post(
|
||||
"/api/admin/users",
|
||||
json={"email": _SECOND_EMAIL, "password": _SECOND_PASSWORD},
|
||||
headers=_HEADERS,
|
||||
)
|
||||
responses = [
|
||||
await client.get("/api/auth/me"),
|
||||
await client.get("/api/admin/users"),
|
||||
await client.get("/api/methodologies"),
|
||||
await client.get("/api/config"),
|
||||
await client.get("/api/runs/abcd2345"),
|
||||
]
|
||||
for response in responses:
|
||||
raw = response.text
|
||||
assert "$argon2" not in raw # no hash material, ever
|
||||
assert '"password_hash"' not in raw # no hash field, ever
|
||||
assert _ADMIN_PASSWORD not in raw
|
||||
assert _SECOND_PASSWORD not in raw
|
||||
@@ -0,0 +1,99 @@
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from researchx.adapters.capability_probe import probe_model_adapter
|
||||
from researchx.adapters.ollama import OllamaModelAdapter
|
||||
from researchx.adapters.probe import DEFAULT_CONTEXT_CAP, DEFAULT_EMBEDDING_BATCH
|
||||
from researchx.adapters.secrets import EnvFileSecretsProvider
|
||||
from researchx.adapters.types import AdapterError
|
||||
|
||||
|
||||
def _secrets(tmp_path, values: dict[str, str]) -> EnvFileSecretsProvider:
|
||||
env_file = tmp_path / ".env.secrets"
|
||||
env_file.write_text("".join(f"{k}={v}\n" for k, v in values.items()), encoding="utf-8")
|
||||
return EnvFileSecretsProvider(paths=[env_file])
|
||||
|
||||
|
||||
def _ok_smoke_response() -> dict[str, object]:
|
||||
return {
|
||||
"choices": [{"message": {"role": "assistant", "content": '{"ok": true}'}}],
|
||||
"usage": {"prompt_tokens": 3, "completion_tokens": 2},
|
||||
}
|
||||
|
||||
|
||||
async def test_probe_green_on_supporting_model(tmp_path, monkeypatch) -> None:
|
||||
secrets = _secrets(tmp_path, {"OLLAMA_API_KEY": "cloud-key"})
|
||||
adapter = OllamaModelAdapter(secrets, planner_model="p", writer_model="w")
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
if str(request.url).endswith("/models"):
|
||||
return httpx.Response(200, json={"data": [{"id": "w"}, {"id": "p"}]})
|
||||
return httpx.Response(200, json=_ok_smoke_response())
|
||||
|
||||
transport = httpx.MockTransport(handler)
|
||||
original = httpx.AsyncClient
|
||||
monkeypatch.setattr(httpx, "AsyncClient", lambda **kwargs: original(transport=transport, **kwargs))
|
||||
|
||||
probe = await probe_model_adapter(adapter)
|
||||
assert probe.endpoint == "https://ollama.com/v1"
|
||||
assert probe.models == ["w", "p"]
|
||||
assert probe.context_window == DEFAULT_CONTEXT_CAP
|
||||
assert probe.embedding_batch == DEFAULT_EMBEDDING_BATCH
|
||||
assert probe.structured_outputs_supported is True
|
||||
assert probe.structured_output_warning is None
|
||||
assert probe.degraded is False
|
||||
|
||||
|
||||
async def test_probe_warns_on_weak_structured_output(tmp_path, monkeypatch) -> None:
|
||||
secrets = _secrets(tmp_path, {"OLLAMA_API_KEY": "cloud-key"})
|
||||
adapter = OllamaModelAdapter(secrets, planner_model="p", writer_model="writer-model")
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
if str(request.url).endswith("/models"):
|
||||
return httpx.Response(200, json={"data": [{"id": "writer-model"}]})
|
||||
return httpx.Response(200, json={"choices": [{"message": {"content": "not json"}}]})
|
||||
|
||||
transport = httpx.MockTransport(handler)
|
||||
original = httpx.AsyncClient
|
||||
monkeypatch.setattr(httpx, "AsyncClient", lambda **kwargs: original(transport=transport, **kwargs))
|
||||
|
||||
probe = await probe_model_adapter(adapter)
|
||||
assert probe.structured_outputs_supported is False
|
||||
assert probe.structured_output_warning is not None
|
||||
assert "writer-model" in probe.structured_output_warning
|
||||
assert "degrade" in probe.structured_output_warning
|
||||
assert probe.degraded is True
|
||||
|
||||
|
||||
async def test_probe_reports_endpoint_down_as_unsupported(tmp_path, monkeypatch) -> None:
|
||||
secrets = _secrets(tmp_path, {"OLLAMA_API_KEY": "cloud-key"})
|
||||
adapter = OllamaModelAdapter(secrets, planner_model="p", writer_model="w")
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
return httpx.Response(500, text="boom")
|
||||
|
||||
transport = httpx.MockTransport(handler)
|
||||
original = httpx.AsyncClient
|
||||
monkeypatch.setattr(httpx, "AsyncClient", lambda **kwargs: original(transport=transport, **kwargs))
|
||||
|
||||
with pytest.raises(AdapterError):
|
||||
await probe_model_adapter(adapter)
|
||||
|
||||
|
||||
async def test_list_models_falls_back_to_local(tmp_path, monkeypatch) -> None:
|
||||
secrets = _secrets(tmp_path, {"OLLAMA_API_KEY": "cloud-key"})
|
||||
adapter = OllamaModelAdapter(secrets, planner_model="p", writer_model="w")
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
if str(request.url).startswith("https://ollama.com"):
|
||||
return httpx.Response(401, json={"error": "unauthorized"})
|
||||
return httpx.Response(200, json={"data": [{"id": "local-model"}]})
|
||||
|
||||
transport = httpx.MockTransport(handler)
|
||||
original = httpx.AsyncClient
|
||||
monkeypatch.setattr(httpx, "AsyncClient", lambda **kwargs: original(transport=transport, **kwargs))
|
||||
|
||||
models = await adapter.list_models()
|
||||
assert models == ["local-model"]
|
||||
assert adapter.active_base_url == "http://localhost:11434/v1"
|
||||
assert adapter.last_fallback_reason is not None
|
||||
@@ -0,0 +1,66 @@
|
||||
import pytest
|
||||
from pydantic import ValidationError
|
||||
|
||||
from researchx.config.dials import DIAL_REGISTRY, DialDescriptor, Dials
|
||||
|
||||
EXPECTED_DIALS = {
|
||||
"source_universe": "web",
|
||||
"search_routing": "single",
|
||||
"query_strategy": "decomposed",
|
||||
"breadth": 2,
|
||||
"depth": 1,
|
||||
"quality_gates": "standard",
|
||||
"citation_rigor": "per-claim",
|
||||
"verification_passes": 1,
|
||||
"synthesis_style": "memo",
|
||||
"output_structure": "brief",
|
||||
"memory_scope": "none",
|
||||
"checkpoints": False,
|
||||
}
|
||||
|
||||
|
||||
def test_defaults_resolve() -> None:
|
||||
assert Dials().model_dump() == EXPECTED_DIALS
|
||||
|
||||
|
||||
def test_all_twelve_dials_present() -> None:
|
||||
assert set(Dials.model_fields) == set(EXPECTED_DIALS)
|
||||
assert len(Dials.model_fields) == 12
|
||||
|
||||
|
||||
def test_registry_covers_all_dials() -> None:
|
||||
assert set(DIAL_REGISTRY) == set(EXPECTED_DIALS)
|
||||
for descriptor in DIAL_REGISTRY.values():
|
||||
assert isinstance(descriptor, DialDescriptor)
|
||||
assert descriptor.label
|
||||
assert descriptor.description
|
||||
|
||||
|
||||
def test_literal_dial_rejects_invalid_value() -> None:
|
||||
with pytest.raises(ValidationError):
|
||||
Dials(source_universe="library")
|
||||
|
||||
|
||||
def test_bounded_int_rejects_out_of_range() -> None:
|
||||
with pytest.raises(ValidationError):
|
||||
Dials(breadth=6)
|
||||
with pytest.raises(ValidationError):
|
||||
Dials(depth=0)
|
||||
with pytest.raises(ValidationError):
|
||||
Dials(verification_passes=-1)
|
||||
|
||||
|
||||
def test_checkpoints_rejects_non_bool() -> None:
|
||||
with pytest.raises(ValidationError):
|
||||
Dials(checkpoints=5)
|
||||
|
||||
|
||||
def test_model_dump_round_trips() -> None:
|
||||
dials = Dials(depth=3, breadth=5, checkpoints=True, synthesis_style="executive")
|
||||
assert Dials.model_validate(dials.model_dump()) == dials
|
||||
|
||||
|
||||
def test_dials_are_immutable() -> None:
|
||||
dials = Dials()
|
||||
with pytest.raises(ValidationError):
|
||||
dials.depth = 3
|
||||
@@ -0,0 +1,183 @@
|
||||
import asyncio
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from researchx.adapters.fakes import CORPUS, FakeModelAdapter, FakeSearchAdapter
|
||||
from researchx.adapters.local_storage import LocalStorageAdapter
|
||||
from researchx.adapters.types import ChatResult
|
||||
from researchx.engine.actor import RunActor, make_run_config
|
||||
from researchx.engine.events import EventBus
|
||||
from researchx.engine.states import RunStateName, can_transition
|
||||
from researchx.methodologies import registry
|
||||
|
||||
_PLAN = json.dumps(
|
||||
{
|
||||
"sub_questions": ["methodology verification", "quote anchoring"],
|
||||
"source_universe": ["web"],
|
||||
"outline": ["intro", "findings"],
|
||||
}
|
||||
)
|
||||
|
||||
_WRITER_REPORT = (
|
||||
"# Report\n\n"
|
||||
"The verification pass re-anchored every quoted claim [p-1]. "
|
||||
"Methodology-first selection converts ad-hoc prompting [p-1] "
|
||||
"into reproducible decisions [p-2].\n"
|
||||
)
|
||||
|
||||
|
||||
def _canned() -> list[ChatResult]:
|
||||
return [
|
||||
ChatResult(content=_PLAN, model="fake-planner"),
|
||||
ChatResult(content=_WRITER_REPORT, model="fake-writer"),
|
||||
]
|
||||
|
||||
|
||||
def _actor(tmp_path: Path) -> tuple[RunActor, EventBus, FakeSearchAdapter]:
|
||||
_run_id, config = make_run_config(
|
||||
question="How do verification pipelines keep citations honest?",
|
||||
context="",
|
||||
methodology_slug="rapid-answer",
|
||||
overrides={},
|
||||
registry=registry,
|
||||
run_id="abcd2345",
|
||||
)
|
||||
model = FakeModelAdapter(canned=_canned())
|
||||
search = FakeSearchAdapter(CORPUS)
|
||||
storage = LocalStorageAdapter(base_dir=tmp_path, owner_id="u1a2b3c4")
|
||||
bus = EventBus()
|
||||
actor = RunActor(
|
||||
run_config=config,
|
||||
model=model,
|
||||
search=search,
|
||||
storage=storage,
|
||||
registry=registry,
|
||||
event_bus=bus,
|
||||
)
|
||||
return actor, bus, search
|
||||
|
||||
|
||||
async def test_full_walk_reaches_done(tmp_path: Path) -> None:
|
||||
actor, bus, _ = _actor(tmp_path)
|
||||
task = asyncio.create_task(actor.run())
|
||||
while actor.state.state != RunStateName.AWAITING_APPROVAL:
|
||||
await asyncio.sleep(0.01)
|
||||
if task.done():
|
||||
break
|
||||
await actor.approve()
|
||||
state = await task
|
||||
assert state.state == RunStateName.DONE
|
||||
assert state.plan is not None and len(state.plan.sub_questions) == 2
|
||||
|
||||
saved = await LocalStorageAdapter(base_dir=tmp_path, owner_id="u1a2b3c4").read_run_artifact("abcd2345", "state.json")
|
||||
assert json.loads(saved)["state"] == "DONE"
|
||||
|
||||
report = await LocalStorageAdapter(base_dir=tmp_path, owner_id="u1a2b3c4").read_run_artifact("abcd2345", "report.md")
|
||||
assert "[p-1]" in report
|
||||
|
||||
ledger_raw = await LocalStorageAdapter(base_dir=tmp_path, owner_id="u1a2b3c4").read_run_artifact(
|
||||
"abcd2345", "ledger.json"
|
||||
)
|
||||
ledger = json.loads(ledger_raw)
|
||||
assert ledger["checklist"]["citations_checked"] >= 1
|
||||
assert ledger["checklist"]["liveness_ratio"] >= 0
|
||||
|
||||
checklist = await LocalStorageAdapter(base_dir=tmp_path, owner_id="u1a2b3c4").read_run_artifact(
|
||||
"abcd2345", "checklist.md"
|
||||
)
|
||||
assert "Verification Checklist" in checklist
|
||||
|
||||
types = [e.type for e in bus.replay()]
|
||||
assert "run_started" in types
|
||||
assert "query_fired" in types
|
||||
assert "source_visited" in types
|
||||
assert "passage_extracted" in types
|
||||
assert "run_done" in types
|
||||
|
||||
|
||||
async def test_halt_at_awaiting_approval_until_approved(tmp_path: Path) -> None:
|
||||
actor, _, _ = _actor(tmp_path)
|
||||
task = asyncio.create_task(actor.run())
|
||||
for _ in range(100):
|
||||
await asyncio.sleep(0.01)
|
||||
if actor.state.state == RunStateName.AWAITING_APPROVAL:
|
||||
break
|
||||
assert actor.state.state == RunStateName.AWAITING_APPROVAL
|
||||
await asyncio.sleep(0.05)
|
||||
assert not task.done()
|
||||
await actor.approve()
|
||||
state = await task
|
||||
assert state.state == RunStateName.DONE
|
||||
|
||||
|
||||
async def test_cancel_persists_cancelled_state(tmp_path: Path) -> None:
|
||||
actor, _, _ = _actor(tmp_path)
|
||||
task = asyncio.create_task(actor.run())
|
||||
while actor.state.state != RunStateName.AWAITING_APPROVAL:
|
||||
await asyncio.sleep(0.01)
|
||||
actor.cancel()
|
||||
await actor.approve()
|
||||
with pytest.raises(asyncio.CancelledError):
|
||||
await task
|
||||
saved = await LocalStorageAdapter(base_dir=tmp_path, owner_id="u1a2b3c4").read_run_artifact("abcd2345", "state.json")
|
||||
assert json.loads(saved)["state"] == "CANCELLED"
|
||||
|
||||
|
||||
async def test_pause_resume_mid_run(tmp_path: Path) -> None:
|
||||
actor, _, _ = _actor(tmp_path)
|
||||
task = asyncio.create_task(actor.run())
|
||||
while actor.state.state != RunStateName.AWAITING_APPROVAL:
|
||||
await asyncio.sleep(0.01)
|
||||
await actor.approve()
|
||||
actor.pause()
|
||||
await asyncio.sleep(0.05)
|
||||
assert not task.done()
|
||||
actor.resume()
|
||||
state = await task
|
||||
assert state.state == RunStateName.DONE
|
||||
|
||||
|
||||
def test_illegal_transitions_rejected() -> None:
|
||||
assert not can_transition(RunStateName.PLANNING, RunStateName.DONE)
|
||||
assert not can_transition(RunStateName.DONE, RunStateName.PLANNING)
|
||||
assert can_transition(RunStateName.PLANNING, RunStateName.AWAITING_APPROVAL)
|
||||
assert can_transition(RunStateName.REFLECTING, RunStateName.SEARCHING)
|
||||
|
||||
|
||||
async def test_planner_non_json_fails_honestly(tmp_path: Path) -> None:
|
||||
_run_id, config = make_run_config(
|
||||
question="q",
|
||||
context="",
|
||||
methodology_slug="rapid-answer",
|
||||
overrides={},
|
||||
registry=registry,
|
||||
run_id="abcd2345",
|
||||
)
|
||||
model = FakeModelAdapter(canned=[ChatResult(content="not json", model="fake-planner")])
|
||||
actor = RunActor(
|
||||
run_config=config,
|
||||
model=model,
|
||||
search=FakeSearchAdapter(CORPUS),
|
||||
storage=LocalStorageAdapter(base_dir=tmp_path, owner_id="u1a2b3c4"),
|
||||
registry=registry,
|
||||
event_bus=EventBus(),
|
||||
)
|
||||
task = asyncio.create_task(actor.run())
|
||||
with pytest.raises(Exception, match="planner returned non-JSON plan"):
|
||||
await task
|
||||
saved = await LocalStorageAdapter(base_dir=tmp_path, owner_id="u1a2b3c4").read_run_artifact("abcd2345", "state.json")
|
||||
assert json.loads(saved)["state"] == "FAILED"
|
||||
|
||||
|
||||
async def test_writer_context_is_visited_only(tmp_path: Path) -> None:
|
||||
actor, _, _ = _actor(tmp_path)
|
||||
task = asyncio.create_task(actor.run())
|
||||
while actor.state.state != RunStateName.AWAITING_APPROVAL:
|
||||
await asyncio.sleep(0.01)
|
||||
await actor.approve()
|
||||
await task
|
||||
assert actor._store.writer_context(), "evidence collected"
|
||||
outside_ids = {p.passage_id for p in actor._store.writer_context()}
|
||||
assert all(pid.startswith("p-") for pid in outside_ids)
|
||||
@@ -0,0 +1,56 @@
|
||||
from researchx.evidence.store import EvidenceStore, extract_passages
|
||||
|
||||
|
||||
def test_extract_passages_min_length() -> None:
|
||||
text = "short\n" + ("x" * 150) + "\nanother short"
|
||||
passages = extract_passages(text)
|
||||
assert passages == ["x" * 150]
|
||||
|
||||
|
||||
def test_store_stable_ids() -> None:
|
||||
store = EvidenceStore()
|
||||
first = store.add_passage("https://a", "first passage text " * 10)
|
||||
second = store.add_passage("https://a", "second passage text " * 10)
|
||||
assert first.passage_id == "p-1"
|
||||
assert second.passage_id == "p-2"
|
||||
|
||||
|
||||
def test_store_add_page_creates_passages() -> None:
|
||||
store = EvidenceStore()
|
||||
body = "\n".join(f"paragraph {i} " + "y" * 150 for i in range(3))
|
||||
store.add_page("https://example.org/a", "Title A", body)
|
||||
assert len(store) == 3
|
||||
page = store.pages()[0]
|
||||
assert page.url == "https://example.org/a"
|
||||
assert page.title == "Title A"
|
||||
assert len(page.passage_ids) == 3
|
||||
|
||||
|
||||
def test_writer_context_contains_only_stored() -> None:
|
||||
store = EvidenceStore()
|
||||
store.add_passage("https://a", "stored passage " * 20)
|
||||
context = store.writer_context()
|
||||
assert len(context) == 1
|
||||
assert context[0].url == "https://a"
|
||||
|
||||
outside = EvidenceStore()
|
||||
outside_context = outside.writer_context()
|
||||
assert outside_context == []
|
||||
|
||||
|
||||
def test_dedupe_by_quote() -> None:
|
||||
store = EvidenceStore()
|
||||
text = "identical passage text " * 10
|
||||
store.add_passage("https://a", text)
|
||||
assert store.has_seen(text[:200])
|
||||
assert not store.has_seen("something never stored " * 20)
|
||||
|
||||
|
||||
def test_passages_for_url() -> None:
|
||||
store = EvidenceStore()
|
||||
store.add_passage("https://a", "from a " * 30)
|
||||
store.add_passage("https://b", "from b " * 30)
|
||||
from_a = store.passages_for_url("https://a")
|
||||
assert len(from_a) == 1
|
||||
assert from_a[0].url == "https://a"
|
||||
assert store.passages_for_url("https://missing") == []
|
||||
@@ -0,0 +1,82 @@
|
||||
import inspect
|
||||
|
||||
from researchx.adapters import fakes
|
||||
from researchx.adapters.fakes import CORPUS, FakeModelAdapter, FakeSearchAdapter, corpus
|
||||
from researchx.adapters.model import ModelAdapter, ModelRoles
|
||||
from researchx.adapters.search import SearchAdapter
|
||||
from researchx.adapters.types import AdapterError, ChatResult, ModelMessage
|
||||
|
||||
|
||||
def _model() -> FakeModelAdapter:
|
||||
return FakeModelAdapter(canned=[ChatResult(content="ok", model="fake-model")])
|
||||
|
||||
|
||||
async def test_fake_model_full_interface() -> None:
|
||||
model = _model()
|
||||
assert isinstance(model, ModelAdapter)
|
||||
result = await model.chat([ModelMessage(role="user", content="hi")], model="fake-model")
|
||||
assert result.content == "ok"
|
||||
assert await model.list_models() == ["fake-model"]
|
||||
assert model.roles() == ModelRoles(planner="fake-planner", writer="fake-writer")
|
||||
|
||||
|
||||
async def test_fake_model_exhausted_raises() -> None:
|
||||
model = _model()
|
||||
await model.chat([ModelMessage(role="user", content="hi")], model="fake-model")
|
||||
try:
|
||||
await model.chat([ModelMessage(role="user", content="again")], model="fake-model")
|
||||
except AdapterError:
|
||||
pass
|
||||
else:
|
||||
raise AssertionError("expected AdapterError")
|
||||
|
||||
|
||||
async def test_fake_model_json_mode_recorded() -> None:
|
||||
model = _model()
|
||||
await model.chat([ModelMessage(role="user", content="hi")], model="fake-model", json_mode=True)
|
||||
assert model.last_json_mode is True
|
||||
|
||||
|
||||
async def test_fake_search_full_interface() -> None:
|
||||
search = FakeSearchAdapter(corpus())
|
||||
assert isinstance(search, SearchAdapter)
|
||||
results = await search.search("verification quoted claim", max_results=3)
|
||||
assert 0 < len(results) <= 3
|
||||
page = await search.fetch(results[0].url)
|
||||
assert page.url == results[0].url
|
||||
|
||||
|
||||
async def test_fake_search_deterministic_order() -> None:
|
||||
search = FakeSearchAdapter(corpus())
|
||||
first = await search.search("methodology", max_results=5)
|
||||
second = await search.search("methodology", max_results=5)
|
||||
assert [r.url for r in first] == [r.url for r in second]
|
||||
|
||||
|
||||
async def test_fake_search_unknown_url_raises() -> None:
|
||||
search = FakeSearchAdapter(corpus())
|
||||
try:
|
||||
await search.fetch("https://example.org/missing")
|
||||
except AdapterError:
|
||||
pass
|
||||
else:
|
||||
raise AssertionError("expected AdapterError")
|
||||
|
||||
|
||||
def test_corpus_shape() -> None:
|
||||
pages = CORPUS
|
||||
assert len(pages) >= 5
|
||||
urls = [p.url for p in pages]
|
||||
assert len(set(urls)) == len(urls)
|
||||
texts = "\n".join(p.text for p in pages)
|
||||
assert "—" in texts or "é" in texts or "近" in texts
|
||||
|
||||
|
||||
def test_corpus_shared_sentence() -> None:
|
||||
sharing = [p for p in CORPUS if fakes.SHARED_SENTENCE in p.text]
|
||||
assert len(sharing) == 2
|
||||
|
||||
|
||||
def test_fakes_no_httpx_dependency() -> None:
|
||||
source = inspect.getsource(fakes)
|
||||
assert "httpx" not in source
|
||||
@@ -0,0 +1,87 @@
|
||||
from datetime import UTC, datetime
|
||||
|
||||
import pytest
|
||||
|
||||
from researchx.adapters.fakes import CORPUS, FakeModelAdapter, FakeSearchAdapter
|
||||
from researchx.adapters.local_storage import LocalStorageAdapter
|
||||
from researchx.adapters.types import ChatResult
|
||||
from researchx.config.run_config import RunConfig, UnknownOverrideError, new_run_id, resolve_dials
|
||||
from researchx.methodologies import registry
|
||||
|
||||
|
||||
async def test_registry_to_fake_model_to_snapshot_round_trip(tmp_path) -> None:
|
||||
methodology = registry.get("deep-report")
|
||||
canned = ChatResult(
|
||||
content='{"sub_questions": ["What is verification?", "How does anchoring work?"]}',
|
||||
model="fake-planner",
|
||||
)
|
||||
model = FakeModelAdapter(canned=[canned])
|
||||
plan_result = await model.chat([], model=model.roles().planner, json_mode=True)
|
||||
assert "sub_questions" in plan_result.content
|
||||
|
||||
run_id = new_run_id()
|
||||
dials = resolve_dials("deep-report", {"breadth": 3}, registry)
|
||||
run_config = RunConfig(
|
||||
run_id=run_id,
|
||||
methodology_slug="deep-report",
|
||||
question="How do verification pipelines keep citations honest?",
|
||||
resolved_dials=dials,
|
||||
overrides={"breadth": 3},
|
||||
created_at=datetime.now(UTC),
|
||||
)
|
||||
assert run_config.resolved_dials.breadth == 3
|
||||
|
||||
storage = LocalStorageAdapter(base_dir=tmp_path, owner_id="u1a2b3c4")
|
||||
await storage.write_run_artifact(run_id, "run_config.json", run_config.model_dump_json())
|
||||
|
||||
restored = RunConfig.model_validate_json(
|
||||
await storage.read_run_artifact(run_id, "run_config.json")
|
||||
)
|
||||
assert restored == run_config
|
||||
assert restored.resolved_dials.depth == methodology.dials.depth
|
||||
|
||||
|
||||
def test_methodology_behavior_difference() -> None:
|
||||
rapid = registry.get("rapid-answer")
|
||||
deep = registry.get("deep-report")
|
||||
assert rapid.dials.depth < deep.dials.depth
|
||||
assert rapid.dials.breadth < deep.dials.breadth
|
||||
assert rapid.output_shape != deep.output_shape
|
||||
assert rapid.dials.synthesis_style != deep.dials.synthesis_style
|
||||
assert rapid.dials.verification_passes < deep.dials.verification_passes
|
||||
assert rapid.dials.output_structure != deep.dials.output_structure
|
||||
|
||||
|
||||
def test_registry_snapshot_immutability() -> None:
|
||||
from pydantic import ValidationError
|
||||
|
||||
methodology = registry.get("rapid-answer")
|
||||
frozen = registry.get("rapid-answer")
|
||||
with pytest.raises(ValidationError):
|
||||
frozen.dials.depth = 99
|
||||
fresh = registry.get("rapid-answer")
|
||||
assert fresh.dials.depth == methodology.dials.depth
|
||||
assert fresh.dials.depth == 1
|
||||
|
||||
|
||||
def test_override_rejects_unknown_dial() -> None:
|
||||
with pytest.raises(UnknownOverrideError):
|
||||
resolve_dials("rapid-answer", {"nonexistent_dial": 1}, registry)
|
||||
|
||||
|
||||
def test_new_run_id_format() -> None:
|
||||
# P-14: one generator, one alphabet (config/ids.py) — the old
|
||||
# token_hex(4) duplicate is gone.
|
||||
run_id = new_run_id()
|
||||
assert len(run_id) == 8
|
||||
alphabet = set("abcdefghjkmnpqrstuvwxyz23456789")
|
||||
assert all(c in alphabet for c in run_id)
|
||||
|
||||
|
||||
async def test_fake_search_feeds_evidence_shape() -> None:
|
||||
search = FakeSearchAdapter(CORPUS)
|
||||
results = await search.search("methodology dials verification", max_results=5)
|
||||
assert len(results) >= 1
|
||||
for result in results:
|
||||
page = await search.fetch(result.url)
|
||||
assert page.text.strip()
|
||||
@@ -0,0 +1,336 @@
|
||||
"""Key-store adversarial route suite (T-2.8 — security-engineer hat).
|
||||
|
||||
Route-level adversarial coverage beyond the unit suite in
|
||||
test_key_store_settings.py: corrupt/tampered envelopes surfacing
|
||||
through the API (typed error, honest degraded tier, never a silent
|
||||
drop and never a 500), raw-key non-leak across the FULL response
|
||||
surface of a session that touches the key store, and the create_run
|
||||
409 honesty on a corrupt envelope (never a wrong-key run, never a
|
||||
silent tier fall).
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from researchx.adapters.fakes import CORPUS, FakeModelAdapter, FakeSearchAdapter
|
||||
from researchx.adapters.model_factory import ModelAdapterFactory
|
||||
from researchx.adapters.secrets import EnvFileSecretsProvider
|
||||
from researchx.adapters.types import ChatResult
|
||||
from researchx.api.app import create_app
|
||||
from researchx.auth.guard import CSRF_HEADER
|
||||
from researchx.auth.key_store import KEK_NAME, KeyStore
|
||||
|
||||
_PLAN = json.dumps(
|
||||
{"sub_questions": ["verification passes"], "source_universe": ["web"], "outline": ["intro"]}
|
||||
)
|
||||
_WRITER = "# Report\n\nClaim [p-1] and claim [p-2].\n"
|
||||
|
||||
_ADMIN_EMAIL = "admin@example.org"
|
||||
_ADMIN_PASSWORD = "correct-horse-battery"
|
||||
_SECOND_EMAIL = "second@example.org"
|
||||
_SECOND_PASSWORD = "second-long-password"
|
||||
|
||||
_HEADERS = {CSRF_HEADER: "1"}
|
||||
_KEK = base64.urlsafe_b64encode(os.urandom(32)).decode()
|
||||
_USER_KEY = "adversarial-user-key-4242"
|
||||
|
||||
|
||||
def _app(tmp_path: Path, secrets_values: dict[str, str]):
|
||||
env_file = tmp_path / ".env.secrets"
|
||||
env_file.write_text(
|
||||
"".join(f"{k}={v}\n" for k, v in secrets_values.items()), encoding="utf-8"
|
||||
)
|
||||
secrets = EnvFileSecretsProvider(paths=[env_file])
|
||||
return create_app(
|
||||
storage_dir=tmp_path,
|
||||
model_adapter=FakeModelAdapter(
|
||||
canned=[
|
||||
ChatResult(content=_PLAN, model="fake-planner"),
|
||||
ChatResult(content=_WRITER, model="fake-writer"),
|
||||
]
|
||||
),
|
||||
search_adapter=FakeSearchAdapter(CORPUS),
|
||||
auth_enabled=True,
|
||||
data_dir=tmp_path,
|
||||
secrets_provider=secrets,
|
||||
model_factory=ModelAdapterFactory(
|
||||
secrets,
|
||||
KeyStore(secrets, tmp_path),
|
||||
cloud_base_url="http://127.0.0.1:1/v1", # hermetic: unroutable
|
||||
local_base_url="http://127.0.0.1:1/v1",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
async def _login_admin(client: httpx.AsyncClient) -> None:
|
||||
await client.post(
|
||||
"/api/auth/register",
|
||||
json={"email": _ADMIN_EMAIL, "password": _ADMIN_PASSWORD, "display_name": "Admin"},
|
||||
headers=_HEADERS,
|
||||
)
|
||||
await client.post(
|
||||
"/api/auth/login",
|
||||
json={"email": _ADMIN_EMAIL, "password": _ADMIN_PASSWORD},
|
||||
headers=_HEADERS,
|
||||
)
|
||||
|
||||
|
||||
async def _store_key(client: httpx.AsyncClient, key: str) -> None:
|
||||
response = await client.put(
|
||||
"/api/config/settings/model-key", json={"key": key}, headers=_HEADERS
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.json()["configured"] is True
|
||||
|
||||
|
||||
def _corrupt_key_file(tmp_path: Path, user_id: str, content: str) -> None:
|
||||
path = tmp_path / "keys" / f"{user_id}.json"
|
||||
assert path.is_file(), "the key must be stored before corrupting it"
|
||||
path.write_text(content, encoding="utf-8")
|
||||
|
||||
|
||||
async def _current_user_id(client: httpx.AsyncClient) -> str:
|
||||
me = await client.get("/api/auth/me")
|
||||
return me.json()["user_id"]
|
||||
|
||||
|
||||
async def _await_terminal_or_approval(client: httpx.AsyncClient, run_id: str) -> str:
|
||||
for _ in range(300):
|
||||
status = (await client.get(f"/api/runs/{run_id}")).json()["status"]
|
||||
if status in ("done", "failed", "cancelled", "awaiting_approval"):
|
||||
return status
|
||||
await asyncio.sleep(0.01)
|
||||
return status
|
||||
|
||||
|
||||
# ------------------------------------------------- corrupt envelope at the API
|
||||
|
||||
|
||||
async def test_config_status_degrades_honestly_on_garbage_envelope(tmp_path: Path) -> None:
|
||||
"""Garbage keys/<uid>.json -> config status 200 with a DEGRADED
|
||||
tier (keyless/server) + the error surfaced in key_status.reason —
|
||||
typed error surfacing, never a silent tier drop, never a 500."""
|
||||
app = _app(tmp_path, {KEK_NAME: _KEK})
|
||||
async with httpx.AsyncClient(
|
||||
transport=httpx.ASGITransport(app=app), base_url="http://testserver"
|
||||
) as client:
|
||||
await _login_admin(client)
|
||||
await _store_key(client, _USER_KEY)
|
||||
user_id = await _current_user_id(client)
|
||||
_corrupt_key_file(tmp_path, user_id, "this is garbage, not json")
|
||||
|
||||
config = await client.get("/api/config")
|
||||
assert config.status_code == 200 # never a 500
|
||||
body = config.json()
|
||||
assert body["resolved_tier"] in ("server", "keyless") # honest degradation
|
||||
assert body["key_store"] == "enabled"
|
||||
assert body["key_status"]["configured"] is False
|
||||
assert body["key_status"]["reason"] is not None
|
||||
assert "corrupt" in body["key_status"]["reason"].lower()
|
||||
|
||||
|
||||
async def test_config_status_degrades_honestly_on_tampered_envelope(tmp_path: Path) -> None:
|
||||
"""A tampered envelope -> same honest degradation + 'failed
|
||||
authentication' reason — never the decrypted key, never a silent
|
||||
'user' tier."""
|
||||
app = _app(tmp_path, {KEK_NAME: _KEK})
|
||||
async with httpx.AsyncClient(
|
||||
transport=httpx.ASGITransport(app=app), base_url="http://testserver"
|
||||
) as client:
|
||||
await _login_admin(client)
|
||||
await _store_key(client, _USER_KEY)
|
||||
user_id = await _current_user_id(client)
|
||||
original = json.loads((tmp_path / "keys" / f"{user_id}.json").read_text())
|
||||
# Flip ciphertext bytes: the envelope stays valid JSON but
|
||||
# decrypt must fail (InvalidTag -> typed 'tampered').
|
||||
ct = base64.urlsafe_b64decode(original["entries"]["OLLAMA_API_KEY"]["ct"] + "===")
|
||||
flipped = bytes([ct[0] ^ 0xFF]) + ct[1:]
|
||||
original["entries"]["OLLAMA_API_KEY"]["ct"] = (
|
||||
base64.urlsafe_b64encode(flipped).decode().rstrip("=")
|
||||
)
|
||||
_corrupt_key_file(tmp_path, user_id, json.dumps(original))
|
||||
|
||||
config = await client.get("/api/config")
|
||||
assert config.status_code == 200
|
||||
body = config.json()
|
||||
assert body["resolved_tier"] in ("server", "keyless")
|
||||
assert "authentication" in body["key_status"]["reason"].lower()
|
||||
assert _USER_KEY not in config.text
|
||||
|
||||
|
||||
async def test_create_run_corrupt_envelope_is_honest_409(tmp_path: Path) -> None:
|
||||
"""create_run with a corrupt stored envelope -> 409 with the typed
|
||||
error — the run is NOT created (never a wrong-key run, never a
|
||||
silent tier fall)."""
|
||||
app = _app(tmp_path, {KEK_NAME: _KEK})
|
||||
async with httpx.AsyncClient(
|
||||
transport=httpx.ASGITransport(app=app), base_url="http://testserver"
|
||||
) as client:
|
||||
await _login_admin(client)
|
||||
await _store_key(client, _USER_KEY)
|
||||
user_id = await _current_user_id(client)
|
||||
_corrupt_key_file(tmp_path, user_id, "{corrupt")
|
||||
|
||||
created = await client.post(
|
||||
"/api/runs", json={"question": "q", "methodology": "rapid-answer"}, headers=_HEADERS
|
||||
)
|
||||
assert created.status_code == 409
|
||||
detail = created.json()["detail"].lower()
|
||||
assert "corrupt" in detail or "key" in detail
|
||||
|
||||
|
||||
async def test_model_key_put_overwrites_after_corruption(tmp_path: Path) -> None:
|
||||
"""Recovery path: a user whose envelope corrupted can PUT a fresh
|
||||
key (atomic overwrite) and return to the 'user' tier."""
|
||||
app = _app(tmp_path, {KEK_NAME: _KEK})
|
||||
async with httpx.AsyncClient(
|
||||
transport=httpx.ASGITransport(app=app), base_url="http://testserver"
|
||||
) as client:
|
||||
await _login_admin(client)
|
||||
await _store_key(client, _USER_KEY)
|
||||
user_id = await _current_user_id(client)
|
||||
_corrupt_key_file(tmp_path, user_id, "garbage")
|
||||
config = await client.get("/api/config")
|
||||
assert config.json()["resolved_tier"] != "user"
|
||||
|
||||
await _store_key(client, "fresh-key-value-9999")
|
||||
config = await client.get("/api/config")
|
||||
body = config.json()
|
||||
assert body["resolved_tier"] == "user"
|
||||
assert body["key_status"]["masked"].endswith("9999")
|
||||
|
||||
|
||||
# ------------------------------------------------------------- leak scan
|
||||
|
||||
|
||||
async def test_no_raw_key_in_full_session_response_surface(tmp_path: Path) -> None:
|
||||
"""Every response of a full session that stores, reads, and runs
|
||||
with a user key — scanned for the raw key material (REQ-73)."""
|
||||
app = _app(tmp_path, {KEK_NAME: _KEK})
|
||||
async with httpx.AsyncClient(
|
||||
transport=httpx.ASGITransport(app=app), base_url="http://testserver"
|
||||
) as client:
|
||||
await _login_admin(client)
|
||||
put = await client.put(
|
||||
"/api/config/settings/model-key",
|
||||
json={"key": _USER_KEY},
|
||||
headers=_HEADERS,
|
||||
)
|
||||
responses: list[httpx.Response] = [put]
|
||||
for path in [
|
||||
"/api/config",
|
||||
"/api/config/settings",
|
||||
"/api/config/settings/model-key",
|
||||
"/api/auth/me",
|
||||
"/api/methodologies",
|
||||
"/api/admin/users",
|
||||
]:
|
||||
responses.append(await client.get(path))
|
||||
responses.append(
|
||||
await client.put(
|
||||
"/api/config/settings", json={"appearance": "dark"}, headers=_HEADERS
|
||||
)
|
||||
)
|
||||
|
||||
# A run executing under the user tier must not leak the key in
|
||||
# any run response either.
|
||||
created = await client.post(
|
||||
"/api/runs", json={"question": "q", "methodology": "rapid-answer"}, headers=_HEADERS
|
||||
)
|
||||
assert created.status_code == 201
|
||||
responses.append(created)
|
||||
run_id = created.json()["id"]
|
||||
status = await _await_terminal_or_approval(client, run_id)
|
||||
responses.append(await client.get(f"/api/runs/{run_id}"))
|
||||
if status == "awaiting_approval":
|
||||
responses.append(
|
||||
await client.post(f"/api/runs/{run_id}/approve", headers=_HEADERS)
|
||||
)
|
||||
|
||||
for response in responses:
|
||||
assert _USER_KEY not in response.text, "raw model key leaked in a response"
|
||||
# And never on disk in plaintext.
|
||||
for path in (tmp_path / "keys").iterdir():
|
||||
assert _USER_KEY not in path.read_text(encoding="utf-8")
|
||||
|
||||
|
||||
# ---------------------------------------------------- AAD bind via the API
|
||||
|
||||
|
||||
async def test_second_user_cannot_shadow_first_users_key(tmp_path: Path) -> None:
|
||||
"""Two users, each with their own key: the second user's tier is
|
||||
'user' via THEIR key, and the first user's key never affects the
|
||||
second user's resolution (per-user AAD bind end to end)."""
|
||||
app = _app(tmp_path, {KEK_NAME: _KEK})
|
||||
transport = httpx.ASGITransport(app=app)
|
||||
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://testserver") as admin:
|
||||
await _login_admin(admin)
|
||||
await _store_key(admin, "first-user-key-aaaa")
|
||||
config = await admin.get("/api/config")
|
||||
assert config.json()["resolved_tier"] == "user"
|
||||
assert config.json()["key_status"]["masked"].endswith("aaaa")
|
||||
await admin.post(
|
||||
"/api/admin/users",
|
||||
json={
|
||||
"email": _SECOND_EMAIL,
|
||||
"password": _SECOND_PASSWORD,
|
||||
"role": "user",
|
||||
},
|
||||
headers=_HEADERS,
|
||||
)
|
||||
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://testserver") as second:
|
||||
await second.post(
|
||||
"/api/auth/login",
|
||||
json={"email": _SECOND_EMAIL, "password": _SECOND_PASSWORD},
|
||||
headers=_HEADERS,
|
||||
)
|
||||
# Before storing a key: keyless (no server key configured).
|
||||
config = await second.get("/api/config")
|
||||
assert config.json()["resolved_tier"] == "keyless"
|
||||
assert "first-user-key" not in config.text
|
||||
|
||||
await _store_key(second, "second-user-key-bbbb")
|
||||
config = await second.get("/api/config")
|
||||
assert config.json()["resolved_tier"] == "user"
|
||||
assert config.json()["key_status"]["masked"].endswith("bbbb")
|
||||
assert "aaaa" not in config.text
|
||||
|
||||
|
||||
# ---------------------------------------------- settings store isolation
|
||||
|
||||
|
||||
async def test_settings_corruption_is_typed_not_crashing(tmp_path: Path) -> None:
|
||||
"""A corrupt settings file surfaces a typed SettingsStoreError —
|
||||
the store fails honestly rather than silently serving defaults
|
||||
over a corrupt payload."""
|
||||
from researchx.auth.settings import SettingsStore, SettingsStoreError
|
||||
|
||||
app = _app(tmp_path, {KEK_NAME: _KEK})
|
||||
async with httpx.AsyncClient(
|
||||
transport=httpx.ASGITransport(app=app), base_url="http://testserver"
|
||||
) as client:
|
||||
await _login_admin(client)
|
||||
user_id = await _current_user_id(client)
|
||||
settings_dir = tmp_path / "settings"
|
||||
settings_dir.mkdir(parents=True, exist_ok=True)
|
||||
(settings_dir / f"{user_id}.json").write_text("{{not json", encoding="utf-8")
|
||||
|
||||
with pytest.raises(SettingsStoreError):
|
||||
await SettingsStore(tmp_path).get(user_id)
|
||||
|
||||
|
||||
def test_key_store_error_is_adapter_error() -> None:
|
||||
"""The typed key-store error IS an AdapterError — the factory and
|
||||
routes can catch one family (C3-06 contract)."""
|
||||
from researchx.adapters.types import AdapterError
|
||||
from researchx.auth.key_store import KeyStoreError
|
||||
|
||||
assert issubclass(KeyStoreError, AdapterError)
|
||||
@@ -0,0 +1,474 @@
|
||||
"""Key store + settings store suite (T-2.2/T-2.3, REQ-72/73).
|
||||
|
||||
The adversarial core of the per-user key store: KEK decode rules
|
||||
(b64/hex/wrong-length/garbage), set/get round-trips, AAD binding
|
||||
(a copied envelope under another user FAILS — never a wrong-key leak),
|
||||
nonce uniqueness, masked-only status, disabled-honesty without a KEK,
|
||||
0600 file permissions, and the settings defaults/update round-trip.
|
||||
|
||||
The STEP-4 adversarial extension (corrupt-entry surfacing, concurrent
|
||||
set_key atomicity, opt-in keyed live tier) lives at the bottom of this
|
||||
file under the "T-2.8 adversarial extension" banner.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from researchx.adapters.secrets import EnvFileSecretsProvider
|
||||
from researchx.auth.key_store import KEK_NAME, KEY_SCOPE, KeyStore, KeyStoreError, load_kek_bytes
|
||||
from researchx.auth.settings import SettingsStore, UserSettings
|
||||
|
||||
_KEK_RAW = os.urandom(32)
|
||||
_KEK_B64 = base64.urlsafe_b64encode(_KEK_RAW).decode()
|
||||
_KEK_HEX = _KEK_RAW.hex()
|
||||
_USER = "abcd2345"
|
||||
_OTHER_USER = "efgh6789"
|
||||
_RAW_KEY = "sk-live-key-abcd1234"
|
||||
|
||||
|
||||
def _secrets_with(values: dict[str, str], tmp_path: Path) -> EnvFileSecretsProvider:
|
||||
env_file = tmp_path / ".env.secrets"
|
||||
env_file.write_text("".join(f"{k}={v}\n" for k, v in values.items()), encoding="utf-8")
|
||||
return EnvFileSecretsProvider(paths=[env_file])
|
||||
|
||||
|
||||
def _enabled_store(tmp_path: Path) -> KeyStore:
|
||||
return KeyStore(_secrets_with({KEK_NAME: _KEK_B64}, tmp_path), tmp_path)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- KEK decode
|
||||
|
||||
|
||||
def test_kek_decode_b64(tmp_path: Path) -> None:
|
||||
secrets = _secrets_with({KEK_NAME: _KEK_B64}, tmp_path)
|
||||
assert load_kek_bytes(secrets) == _KEK_RAW
|
||||
|
||||
|
||||
def test_kek_decode_hex(tmp_path: Path) -> None:
|
||||
secrets = _secrets_with({KEK_NAME: _KEK_HEX}, tmp_path)
|
||||
assert load_kek_bytes(secrets) == _KEK_RAW
|
||||
|
||||
|
||||
def test_kek_wrong_length_raises(tmp_path: Path) -> None:
|
||||
short = base64.urlsafe_b64encode(b"only-16-bytes!!").decode()
|
||||
with pytest.raises(KeyStoreError) as exc_info:
|
||||
load_kek_bytes(_secrets_with({KEK_NAME: short}, tmp_path))
|
||||
assert "32 bytes" in str(exc_info.value)
|
||||
assert exc_info.value.kind == "bad_kek"
|
||||
|
||||
|
||||
def test_kek_garbage_raises(tmp_path: Path) -> None:
|
||||
with pytest.raises(KeyStoreError) as exc_info:
|
||||
load_kek_bytes(_secrets_with({KEK_NAME: "!!!not-base64!!!"}, tmp_path))
|
||||
assert exc_info.value.kind == "bad_kek"
|
||||
|
||||
|
||||
def test_kek_absent_is_none_not_error(tmp_path: Path) -> None:
|
||||
assert load_kek_bytes(_secrets_with({}, tmp_path)) is None
|
||||
|
||||
|
||||
def test_bad_kek_disables_store_honestly(tmp_path: Path) -> None:
|
||||
"""A malformed KEK is not a crash and not a silent enable."""
|
||||
store = KeyStore(_secrets_with({KEK_NAME: "garbage-value"}, tmp_path), tmp_path)
|
||||
assert store.enabled is False
|
||||
assert store.disabled_reason is not None
|
||||
assert "misconfigured" in store.disabled_reason
|
||||
|
||||
|
||||
# ------------------------------------------------------------- round trips
|
||||
|
||||
|
||||
async def test_set_get_round_trip(tmp_path: Path) -> None:
|
||||
store = _enabled_store(tmp_path)
|
||||
await store.set_key(_USER, KEY_SCOPE, _RAW_KEY)
|
||||
assert await store.get_key(_USER) == _RAW_KEY
|
||||
|
||||
|
||||
async def test_get_without_set_is_none(tmp_path: Path) -> None:
|
||||
store = _enabled_store(tmp_path)
|
||||
assert await store.get_key(_USER) is None
|
||||
|
||||
|
||||
async def test_set_overwrites_previous_key(tmp_path: Path) -> None:
|
||||
store = _enabled_store(tmp_path)
|
||||
await store.set_key(_USER, KEY_SCOPE, "first-key-value")
|
||||
await store.set_key(_USER, KEY_SCOPE, "second-key-value")
|
||||
assert await store.get_key(_USER) == "second-key-value"
|
||||
|
||||
|
||||
async def test_envelope_shape_on_disk(tmp_path: Path) -> None:
|
||||
store = _enabled_store(tmp_path)
|
||||
await store.set_key(_USER, KEY_SCOPE, _RAW_KEY)
|
||||
payload = json.loads((tmp_path / "keys" / f"{_USER}.json").read_text(encoding="utf-8"))
|
||||
assert payload["v"] == 1
|
||||
assert payload["alg"] == "AES-256-GCM"
|
||||
assert "updated_at" in payload
|
||||
entry = payload["entries"][KEY_SCOPE]
|
||||
assert set(entry) == {"nonce", "ct", "updated_at"}
|
||||
# Raw key material must not appear anywhere in the file.
|
||||
assert _RAW_KEY not in json.dumps(payload)
|
||||
|
||||
|
||||
# -------------------------------------------------------------------- AAD
|
||||
|
||||
|
||||
async def test_aad_tamper_copy_to_other_user_fails(tmp_path: Path) -> None:
|
||||
"""The AAD is user_id: a file copied under another user's id FAILS
|
||||
decrypt with a typed error — never returns the other user's key."""
|
||||
store = _enabled_store(tmp_path)
|
||||
await store.set_key(_USER, KEY_SCOPE, _RAW_KEY)
|
||||
|
||||
keys_dir = tmp_path / "keys"
|
||||
(keys_dir / f"{_OTHER_USER}.json").write_text(
|
||||
(keys_dir / f"{_USER}.json").read_text(encoding="utf-8"),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
with pytest.raises(KeyStoreError) as exc_info:
|
||||
await store.get_key(_OTHER_USER)
|
||||
assert exc_info.value.kind == "tampered"
|
||||
# Never the wrong user's key:
|
||||
assert exc_info.value.args[0] != _RAW_KEY
|
||||
|
||||
|
||||
async def test_aad_tamper_masked_status_also_fails(tmp_path: Path) -> None:
|
||||
"""Status must not silently report configured=False for a tampered
|
||||
envelope — that would be a silent tier drop."""
|
||||
store = _enabled_store(tmp_path)
|
||||
await store.set_key(_USER, KEY_SCOPE, _RAW_KEY)
|
||||
keys_dir = tmp_path / "keys"
|
||||
(keys_dir / f"{_OTHER_USER}.json").write_text(
|
||||
(keys_dir / f"{_USER}.json").read_text(encoding="utf-8"),
|
||||
encoding="utf-8",
|
||||
)
|
||||
with pytest.raises(KeyStoreError):
|
||||
await store.masked_status(_OTHER_USER)
|
||||
|
||||
|
||||
async def test_kek_change_surfaces_as_tamper(tmp_path: Path) -> None:
|
||||
"""A re-provisioned KEK cannot read old envelopes — typed error,
|
||||
never silent None (documented loss posture, G-105)."""
|
||||
store = _enabled_store(tmp_path)
|
||||
await store.set_key(_USER, KEY_SCOPE, _RAW_KEY)
|
||||
|
||||
new_kek = base64.urlsafe_b64encode(os.urandom(32)).decode()
|
||||
rotated = KeyStore(_secrets_with({KEK_NAME: new_kek}, tmp_path), tmp_path)
|
||||
with pytest.raises(KeyStoreError) as exc_info:
|
||||
await rotated.get_key(_USER)
|
||||
assert exc_info.value.kind == "tampered"
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ nonces
|
||||
|
||||
|
||||
async def test_nonce_unique_across_writes(tmp_path: Path) -> None:
|
||||
store = _enabled_store(tmp_path)
|
||||
nonces = []
|
||||
for i in range(3):
|
||||
await store.set_key(_USER, KEY_SCOPE, f"key-number-{i}")
|
||||
payload = json.loads((tmp_path / "keys" / f"{_USER}.json").read_text(encoding="utf-8"))
|
||||
nonces.append(payload["entries"][KEY_SCOPE]["nonce"])
|
||||
assert len(set(nonces)) == 3 # a GCM nonce must NEVER repeat under a KEK
|
||||
|
||||
|
||||
async def test_nonce_is_12_bytes(tmp_path: Path) -> None:
|
||||
store = _enabled_store(tmp_path)
|
||||
await store.set_key(_USER, KEY_SCOPE, _RAW_KEY)
|
||||
payload = json.loads((tmp_path / "keys" / f"{_USER}.json").read_text(encoding="utf-8"))
|
||||
nonce_b64 = payload["entries"][KEY_SCOPE]["nonce"]
|
||||
assert len(base64.urlsafe_b64decode(nonce_b64 + "===")) == 12
|
||||
|
||||
|
||||
# ------------------------------------------------------------ masked status
|
||||
|
||||
|
||||
async def test_masked_status_never_returns_raw_key(tmp_path: Path) -> None:
|
||||
store = _enabled_store(tmp_path)
|
||||
await store.set_key(_USER, KEY_SCOPE, _RAW_KEY)
|
||||
status = await store.masked_status(_USER)
|
||||
assert status["configured"] is True
|
||||
assert status["masked"].endswith(_RAW_KEY[-4:])
|
||||
assert _RAW_KEY not in json.dumps(status)
|
||||
assert status["masked"].startswith("…"[:1]) or status["masked"][0] in ("…", ".")
|
||||
|
||||
|
||||
async def test_masked_status_unconfigured(tmp_path: Path) -> None:
|
||||
store = _enabled_store(tmp_path)
|
||||
status = await store.masked_status(_USER)
|
||||
assert status["configured"] is False
|
||||
assert status["masked"] is None
|
||||
|
||||
|
||||
# -------------------------------------------------------- disabled honesty
|
||||
|
||||
|
||||
async def test_disabled_store_set_rejected_honestly(tmp_path: Path) -> None:
|
||||
store = KeyStore(_secrets_with({}, tmp_path), tmp_path) # no KEK
|
||||
assert store.enabled is False
|
||||
with pytest.raises(KeyStoreError) as exc_info:
|
||||
await store.set_key(_USER, KEY_SCOPE, _RAW_KEY)
|
||||
assert exc_info.value.kind == "disabled"
|
||||
assert "RESEARCHX_KEK" in str(exc_info.value)
|
||||
# And nothing was written to disk.
|
||||
assert not (tmp_path / "keys" / f"{_USER}.json").exists()
|
||||
|
||||
|
||||
async def test_disabled_store_get_returns_none(tmp_path: Path) -> None:
|
||||
store = KeyStore(_secrets_with({}, tmp_path), tmp_path)
|
||||
assert await store.get_key(_USER) is None
|
||||
|
||||
|
||||
async def test_disabled_store_status_says_disabled(tmp_path: Path) -> None:
|
||||
store = KeyStore(_secrets_with({}, tmp_path), tmp_path)
|
||||
status = await store.masked_status(_USER)
|
||||
assert status["disabled"] is True
|
||||
assert "RESEARCHX_KEK" in str(status["reason"])
|
||||
assert status["configured"] is False
|
||||
|
||||
|
||||
async def test_disabled_store_with_stored_key_surfaces_error(tmp_path: Path) -> None:
|
||||
"""The loss posture made visible: a KEK vanished after keys were
|
||||
stored — reads must surface an error state, never silently None
|
||||
(which would look like 'no key configured')."""
|
||||
store = _enabled_store(tmp_path)
|
||||
await store.set_key(_USER, KEY_SCOPE, _RAW_KEY)
|
||||
|
||||
gone = KeyStore(_secrets_with({}, tmp_path), tmp_path)
|
||||
with pytest.raises(KeyStoreError) as exc_info:
|
||||
await gone.get_key(_USER)
|
||||
assert exc_info.value.kind == "disabled"
|
||||
with pytest.raises(KeyStoreError):
|
||||
await gone.masked_status(_USER)
|
||||
|
||||
|
||||
async def test_unsupported_scope_rejected(tmp_path: Path) -> None:
|
||||
store = _enabled_store(tmp_path)
|
||||
with pytest.raises(KeyStoreError):
|
||||
await store.set_key(_USER, "TAVILY_API_KEY", "should-not-work")
|
||||
|
||||
|
||||
# ------------------------------------------------------------ file perms
|
||||
|
||||
|
||||
async def test_key_file_perms_0600(tmp_path: Path) -> None:
|
||||
store = _enabled_store(tmp_path)
|
||||
await store.set_key(_USER, KEY_SCOPE, _RAW_KEY)
|
||||
mode = (tmp_path / "keys" / f"{_USER}.json").stat().st_mode & 0o777
|
||||
assert mode == 0o600
|
||||
|
||||
|
||||
def test_key_store_docstring_documents_loss_posture() -> None:
|
||||
"""G-105: the loss posture must be stated where the keys live."""
|
||||
import researchx.auth.key_store as key_store_module
|
||||
|
||||
doc = key_store_module.__doc__ or ""
|
||||
assert "KEK-loss" in doc or "unrecoverable" in doc
|
||||
assert "back up the KEK" in doc
|
||||
|
||||
|
||||
# ------------------------------------------------------------- settings
|
||||
|
||||
|
||||
async def test_settings_defaults_for_fresh_user(tmp_path: Path) -> None:
|
||||
store = SettingsStore(tmp_path)
|
||||
settings = await store.get(_USER)
|
||||
assert settings.planner_model
|
||||
assert settings.writer_model
|
||||
assert settings.methodology_defaults == {}
|
||||
assert settings.appearance == "system"
|
||||
# GET never writes:
|
||||
assert not (tmp_path / "settings" / f"{_USER}.json").exists()
|
||||
|
||||
|
||||
async def test_settings_update_round_trip(tmp_path: Path) -> None:
|
||||
store = SettingsStore(tmp_path)
|
||||
updated = await store.update(_USER, UserSettings(planner_model="m1", writer_model="m2"))
|
||||
assert updated.planner_model == "m1"
|
||||
assert updated.writer_model == "m2"
|
||||
assert updated.appearance == "system" # untouched field keeps default
|
||||
|
||||
reloaded = await store.get(_USER)
|
||||
assert reloaded.planner_model == "m1"
|
||||
assert reloaded.methodology_defaults == {}
|
||||
|
||||
|
||||
async def test_settings_partial_update_preserves_other_fields(tmp_path: Path) -> None:
|
||||
store = SettingsStore(tmp_path)
|
||||
await store.update(_USER, UserSettings(planner_model="keep-planner", appearance="dark"))
|
||||
updated = await store.update(_USER, UserSettings(appearance="light"))
|
||||
assert updated.planner_model == "keep-planner" # survived the partial write
|
||||
assert updated.appearance == "light"
|
||||
|
||||
|
||||
async def test_settings_methodology_defaults_round_trip(tmp_path: Path) -> None:
|
||||
store = SettingsStore(tmp_path)
|
||||
payload = UserSettings(methodology_defaults={"rapid-answer": {"depth": 2}})
|
||||
await store.update(_USER, payload)
|
||||
reloaded = await store.get(_USER)
|
||||
assert reloaded.methodology_defaults == {"rapid-answer": {"depth": 2}}
|
||||
|
||||
|
||||
async def test_settings_update_validates_merged_payload_before_write(tmp_path: Path) -> None:
|
||||
"""P2-verify P0 regression: the store never writes a payload it
|
||||
cannot read back — a null field that slipped past model_copy (which
|
||||
skips validation) is refused before touching disk."""
|
||||
store = SettingsStore(tmp_path)
|
||||
await store.update(_USER, UserSettings(planner_model="keep-me"))
|
||||
poisoned = await store.get(_USER)
|
||||
poisoned = poisoned.model_copy(update={"planner_model": None})
|
||||
with pytest.raises(ValueError):
|
||||
await store.update(_USER, poisoned)
|
||||
reloaded = await store.get(_USER)
|
||||
assert reloaded.planner_model == "keep-me" # on-disk payload untouched
|
||||
|
||||
|
||||
async def test_settings_isolated_per_user(tmp_path: Path) -> None:
|
||||
store = SettingsStore(tmp_path)
|
||||
await store.update(_USER, UserSettings(appearance="dark"))
|
||||
other = await store.get(_OTHER_USER)
|
||||
assert other.appearance == "system" # fresh defaults, no bleed
|
||||
assert not (tmp_path / "settings" / f"{_OTHER_USER}.json").exists()
|
||||
|
||||
|
||||
async def test_settings_corrupt_payload_surfaces_typed_error(tmp_path: Path) -> None:
|
||||
store = SettingsStore(tmp_path)
|
||||
settings_dir = tmp_path / "settings"
|
||||
settings_dir.mkdir(parents=True, exist_ok=True)
|
||||
(settings_dir / f"{_USER}.json").write_text("{not json", encoding="utf-8")
|
||||
from researchx.auth.settings import SettingsStoreError
|
||||
|
||||
with pytest.raises((SettingsStoreError, ValueError)):
|
||||
await store.get(_USER)
|
||||
|
||||
|
||||
# ================================================== T-2.8 adversarial extension
|
||||
# (security-engineer hat — corrupt-entry surfacing, concurrent-set
|
||||
# atomicity, and the opt-in keyed live tier. See STEP 4.)
|
||||
|
||||
|
||||
async def test_corrupt_envelope_surfaces_typed_error_never_silent_drop(tmp_path: Path) -> None:
|
||||
"""Garbage in keys/<uid>.json → KeyStoreError('unreadable'), never
|
||||
a silent None (which would silently drop the user's tier)."""
|
||||
store = _enabled_store(tmp_path)
|
||||
keys_dir = tmp_path / "keys"
|
||||
keys_dir.mkdir(parents=True, exist_ok=True)
|
||||
(keys_dir / f"{_USER}.json").write_text("this is not json at all", encoding="utf-8")
|
||||
with pytest.raises(KeyStoreError) as exc_info:
|
||||
await store.get_key(_USER)
|
||||
assert exc_info.value.kind == "unreadable"
|
||||
|
||||
|
||||
async def test_corrupt_entry_fields_surface_typed_error(tmp_path: Path) -> None:
|
||||
"""Structurally-valid JSON with missing/invalid fields — typed error."""
|
||||
store = _enabled_store(tmp_path)
|
||||
keys_dir = tmp_path / "keys"
|
||||
keys_dir.mkdir(parents=True, exist_ok=True)
|
||||
(keys_dir / f"{_USER}.json").write_text(
|
||||
json.dumps({"v": 1, "alg": "AES-256-GCM", "entries": {"OLLAMA_API_KEY": {"nonce": "xx"}}}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
with pytest.raises(KeyStoreError):
|
||||
await store.get_key(_USER)
|
||||
|
||||
|
||||
async def test_bit_rot_ciphertext_surfaces_tamper(tmp_path: Path) -> None:
|
||||
"""Flipping ciphertext bytes → InvalidTag → typed 'tampered'."""
|
||||
store = _enabled_store(tmp_path)
|
||||
await store.set_key(_USER, KEY_SCOPE, _RAW_KEY)
|
||||
path = tmp_path / "keys" / f"{_USER}.json"
|
||||
payload = json.loads(path.read_text(encoding="utf-8"))
|
||||
ct = base64.urlsafe_b64decode(payload["entries"][KEY_SCOPE]["ct"] + "===")
|
||||
flipped = bytes([ct[0] ^ 0xFF]) + ct[1:]
|
||||
payload["entries"][KEY_SCOPE]["ct"] = base64.urlsafe_b64encode(flipped).decode().rstrip("=")
|
||||
path.write_text(json.dumps(payload), encoding="utf-8")
|
||||
with pytest.raises(KeyStoreError) as exc_info:
|
||||
await store.get_key(_USER)
|
||||
assert exc_info.value.kind == "tampered"
|
||||
|
||||
|
||||
async def test_concurrent_set_key_never_tears_the_file(tmp_path: Path) -> None:
|
||||
"""Concurrent writes to the same user: every reader (including a
|
||||
second KeyStore handle over the same root) sees a valid envelope —
|
||||
the tmp+replace idiom leaves no torn files."""
|
||||
store = _enabled_store(tmp_path)
|
||||
await asyncio.gather(
|
||||
*(store.set_key(_USER, KEY_SCOPE, f"concurrent-key-{i}") for i in range(16))
|
||||
)
|
||||
# The final file is one of the written keys, decryptable, well-formed.
|
||||
final = await store.get_key(_USER)
|
||||
assert final is not None
|
||||
assert final.startswith("concurrent-key-")
|
||||
|
||||
# A second handle (fresh process view) reads the same valid file.
|
||||
second = KeyStore(_secrets_with({KEK_NAME: _KEK_B64}, tmp_path), tmp_path)
|
||||
again = await second.get_key(_USER)
|
||||
assert again == final
|
||||
payload = json.loads((tmp_path / "keys" / f"{_USER}.json").read_text(encoding="utf-8"))
|
||||
assert payload["v"] == 1 # full envelope, never a partial write
|
||||
# No tmp strays left behind.
|
||||
assert [p.name for p in (tmp_path / "keys").iterdir() if p.name.endswith(".tmp")] == []
|
||||
|
||||
|
||||
async def test_set_key_atomic_under_concurrent_reads(tmp_path: Path) -> None:
|
||||
"""Reads racing a write never observe a torn file."""
|
||||
store = _enabled_store(tmp_path)
|
||||
await store.set_key(_USER, KEY_SCOPE, "initial-key")
|
||||
|
||||
async def _reader() -> str | None:
|
||||
for _ in range(20):
|
||||
try:
|
||||
key = await store.get_key(_USER)
|
||||
if key is None:
|
||||
raise AssertionError("envelope vanished mid-race")
|
||||
except KeyStoreError as exc:
|
||||
raise AssertionError(f"torn read surfaced: {exc}") from exc
|
||||
return None
|
||||
|
||||
async def _writer() -> None:
|
||||
for i in range(10):
|
||||
await store.set_key(_USER, KEY_SCOPE, f"racing-key-{i}")
|
||||
|
||||
await asyncio.gather(_reader(), _writer())
|
||||
assert (await store.get_key(_USER)).startswith("racing-key-")
|
||||
|
||||
|
||||
def test_keyed_live_tier_marker_registered() -> None:
|
||||
"""The opt-in keyed live tier is marked `live` and skipped by
|
||||
default (same pattern as test_live_smoke.py): the marker must be
|
||||
registered in the pytest config so the opt-in test below skips
|
||||
cleanly in zero-key CI."""
|
||||
import tomllib
|
||||
|
||||
config = tomllib.loads((Path(__file__).resolve().parents[1] / "pyproject.toml").read_text())
|
||||
markers = config["tool"]["pytest"]["ini_options"]["markers"]
|
||||
assert any(str(m).startswith("live:") for m in markers)
|
||||
|
||||
|
||||
# ------------------------------------------------------- opt-in live tier
|
||||
|
||||
|
||||
@pytest.mark.live
|
||||
async def test_live_keyed_tier_end_to_end(tmp_path: Path) -> None:
|
||||
"""Opt-in keyed tier (skipped by default, like test_live_smoke):
|
||||
with a real RESEARCHX_KEK + OLLAMA_API_KEY in the secrets scope, a
|
||||
keyed user gets real inference through the REQ-74 chain."""
|
||||
secrets_paths = [
|
||||
Path.cwd() / ".ciagent" / ".env.secrets",
|
||||
Path.cwd().parents[1] / ".ciagent" / ".env.secrets",
|
||||
]
|
||||
env = EnvFileSecretsProvider(paths=secrets_paths)
|
||||
kek = env.resolve(KEK_NAME)
|
||||
model_key = env.resolve("OLLAMA_API_KEY")
|
||||
if not kek or not model_key:
|
||||
pytest.skip("RESEARCHX_KEK/OLLAMA_API_KEY not present — live tier is opt-in")
|
||||
|
||||
store = KeyStore(env, tmp_path)
|
||||
assert store.enabled, "with a configured KEK the store must be enabled"
|
||||
await store.set_key("live0001", KEY_SCOPE, model_key)
|
||||
resolved = await store.get_key("live0001")
|
||||
assert resolved == model_key
|
||||
@@ -0,0 +1,64 @@
|
||||
"""Opt-in live smoke tier (G-004): real-adapter probes, keyed only.
|
||||
|
||||
These tests NEVER run in the zero-key CI suite: they are marked
|
||||
`live` and skip cleanly when OLLAMA_API_KEY / TAVILY_API_KEY are
|
||||
absent from the secrets scope. Run explicitly with:
|
||||
|
||||
uv run pytest -m live
|
||||
"""
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from researchx.adapters.capability_probe import probe_model_adapter
|
||||
from researchx.adapters.ollama import OllamaModelAdapter
|
||||
from researchx.adapters.secrets import EnvFileSecretsProvider
|
||||
from researchx.adapters.tavily import TavilySearchAdapter
|
||||
from researchx.trust.liveness import LivenessStatus, check_liveness
|
||||
|
||||
pytestmark = pytest.mark.live
|
||||
|
||||
_SECRETS_PATHS = [
|
||||
Path.cwd() / ".ciagent" / ".env.secrets",
|
||||
Path.cwd().parents[1] / ".ciagent" / ".env.secrets",
|
||||
]
|
||||
|
||||
|
||||
def _secrets() -> EnvFileSecretsProvider:
|
||||
return EnvFileSecretsProvider(paths=_SECRETS_PATHS)
|
||||
|
||||
|
||||
def _needs_key(key: str) -> str:
|
||||
value = _secrets().resolve(key) or os.environ.get(key)
|
||||
if not value:
|
||||
pytest.skip(f"{key} not present — live tier is opt-in")
|
||||
return value
|
||||
|
||||
|
||||
async def test_live_ollama_probe_and_chat() -> None:
|
||||
_needs_key("OLLAMA_API_KEY")
|
||||
adapter = OllamaModelAdapter(
|
||||
_secrets(), planner_model="gpt-oss:20b", writer_model="gpt-oss:20b"
|
||||
)
|
||||
probe = await probe_model_adapter(adapter)
|
||||
assert probe.endpoint in ("https://ollama.com/v1", "http://localhost:11434/v1")
|
||||
assert probe.context_window <= 8192
|
||||
assert probe.embedding_batch == 8
|
||||
|
||||
|
||||
async def test_live_tavily_search() -> None:
|
||||
_needs_key("TAVILY_API_KEY")
|
||||
adapter = TavilySearchAdapter(_secrets())
|
||||
results = await adapter.search("deep research agent verification", max_results=3)
|
||||
assert 1 <= len(results) <= 3
|
||||
assert all(r.url.startswith("http") for r in results)
|
||||
|
||||
|
||||
async def test_live_liveness_against_real_page() -> None:
|
||||
status = await check_liveness("https://example.org/")
|
||||
assert status in (LivenessStatus.LIVE, LivenessStatus.STALE, LivenessStatus.DEAD)
|
||||
void_httpx = httpx.AsyncClient
|
||||
del void_httpx
|
||||
@@ -0,0 +1,126 @@
|
||||
from pathlib import Path
|
||||
|
||||
from researchx.adapters.local_storage import LocalStorageAdapter
|
||||
from researchx.adapters.storage import StorageAdapter
|
||||
from researchx.adapters.types import AdapterError
|
||||
|
||||
_OWNER = "u1a2b3c4"
|
||||
|
||||
|
||||
def _storage(tmp_path: Path) -> LocalStorageAdapter:
|
||||
return LocalStorageAdapter(base_dir=tmp_path, owner_id=_OWNER)
|
||||
|
||||
|
||||
async def test_storage_round_trip(tmp_path: Path) -> None:
|
||||
storage = _storage(tmp_path)
|
||||
assert isinstance(storage, StorageAdapter)
|
||||
target = await storage.write_run_artifact("abcd2345", "state.json", '{"state": "PLANNING"}')
|
||||
assert target.exists()
|
||||
content = await storage.read_run_artifact("abcd2345", "state.json")
|
||||
assert content == '{"state": "PLANNING"}'
|
||||
|
||||
|
||||
async def test_atomic_overwrite(tmp_path: Path) -> None:
|
||||
storage = _storage(tmp_path)
|
||||
await storage.write_run_artifact("abcd2345", "state.json", '{"v": 1}')
|
||||
await storage.write_run_artifact("abcd2345", "state.json", '{"v": 2}')
|
||||
assert await storage.read_run_artifact("abcd2345", "state.json") == '{"v": 2}'
|
||||
leftovers = list((tmp_path / "runs" / _OWNER / "abcd2345").glob("*.tmp"))
|
||||
assert leftovers == []
|
||||
|
||||
|
||||
async def test_run_dir_layout(tmp_path: Path) -> None:
|
||||
storage = _storage(tmp_path)
|
||||
run_dir = storage.run_dir("abcd2345")
|
||||
# D-106: runs/<owner_id>/<run_id>/ partitioning (supersedes D-010).
|
||||
assert run_dir == tmp_path / "runs" / _OWNER / "abcd2345"
|
||||
await storage.write_run_artifact("abcd2345", "report.md", "# Report")
|
||||
assert run_dir.is_dir()
|
||||
|
||||
|
||||
async def test_owner_id_required_and_validated(tmp_path: Path) -> None:
|
||||
for bad_owner in ["", "short", "waytoolongid123", "ABCD2345", "a b c d e", "../evil"]:
|
||||
try:
|
||||
LocalStorageAdapter(base_dir=tmp_path, owner_id=bad_owner)
|
||||
except AdapterError:
|
||||
pass
|
||||
else:
|
||||
raise AssertionError(f"expected AdapterError for owner_id: {bad_owner!r}")
|
||||
# owner_id is required — no default partition key.
|
||||
try:
|
||||
LocalStorageAdapter(base_dir=tmp_path) # type: ignore[call-arg]
|
||||
except TypeError:
|
||||
pass
|
||||
else:
|
||||
raise AssertionError("owner_id must be a required positional parameter")
|
||||
|
||||
|
||||
async def test_read_missing_error_embeds_owner_path(tmp_path: Path) -> None:
|
||||
storage = _storage(tmp_path)
|
||||
try:
|
||||
await storage.read_run_artifact("zzzz9999", "state.json")
|
||||
except AdapterError as exc:
|
||||
assert f"runs/{_OWNER}/zzzz9999/state.json" in str(exc)
|
||||
else:
|
||||
raise AssertionError("expected AdapterError")
|
||||
|
||||
|
||||
async def test_read_missing_raises(tmp_path: Path) -> None:
|
||||
storage = _storage(tmp_path)
|
||||
try:
|
||||
await storage.read_run_artifact("zzzz9999", "state.json")
|
||||
except AdapterError:
|
||||
pass
|
||||
else:
|
||||
raise AssertionError("expected AdapterError")
|
||||
|
||||
|
||||
async def test_path_traversal_rejected(tmp_path: Path) -> None:
|
||||
storage = _storage(tmp_path)
|
||||
for bad_name in ["../escape.md", "/etc/passwd", "a/b.md", "", "."]:
|
||||
try:
|
||||
await storage.write_run_artifact("abcd2345", bad_name, "x")
|
||||
except AdapterError:
|
||||
pass
|
||||
else:
|
||||
raise AssertionError(f"expected AdapterError for name: {bad_name!r}")
|
||||
for bad_run_id in ["../evil", "short", "waytoolongid123", "ABCD2345", "a b c d e"]:
|
||||
try:
|
||||
await storage.write_run_artifact(bad_run_id, "state.json", "x")
|
||||
except AdapterError:
|
||||
pass
|
||||
else:
|
||||
raise AssertionError(f"expected AdapterError for run_id: {bad_run_id!r}")
|
||||
|
||||
|
||||
async def test_no_tmp_leftover_after_write(tmp_path: Path) -> None:
|
||||
storage = _storage(tmp_path)
|
||||
await storage.write_run_artifact("abcd2345", "state.json", '{"v": 1}')
|
||||
leftovers = list((tmp_path / "runs" / _OWNER / "abcd2345").glob("*.tmp"))
|
||||
assert leftovers == []
|
||||
|
||||
|
||||
async def test_partition_isolation_between_owners(tmp_path: Path) -> None:
|
||||
alice = LocalStorageAdapter(base_dir=tmp_path, owner_id="aaaa1111")
|
||||
bob = LocalStorageAdapter(base_dir=tmp_path, owner_id="bbbb2222")
|
||||
await alice.write_run_artifact("abcd2345", "state.json", '{"owner": "alice"}')
|
||||
try:
|
||||
await bob.read_run_artifact("abcd2345", "state.json")
|
||||
except AdapterError:
|
||||
pass
|
||||
else:
|
||||
raise AssertionError("owner partitions must be isolated on disk")
|
||||
|
||||
|
||||
async def test_list_run_ids_scans_owner_partition(tmp_path: Path) -> None:
|
||||
storage = _storage(tmp_path)
|
||||
assert storage.list_run_ids() == []
|
||||
await storage.write_run_artifact("abcd2345", "state.json", "{}")
|
||||
await storage.write_run_artifact("efgh6789", "state.json", "{}")
|
||||
# A file (not a dir) and dotfiles are skipped defensively.
|
||||
(tmp_path / "runs" / _OWNER / "notadir.md").write_text("x")
|
||||
(tmp_path / "runs" / _OWNER / ".hidden").mkdir()
|
||||
assert storage.list_run_ids() == ["abcd2345", "efgh6789"]
|
||||
|
||||
other = LocalStorageAdapter(base_dir=tmp_path, owner_id="zzzz9999")
|
||||
assert other.list_run_ids() == [] # separate partition, empty
|
||||
@@ -0,0 +1,312 @@
|
||||
"""Production entrypoint suite (T-2.7 — P-3 backend closure).
|
||||
|
||||
The live app built by ``researchx.api.main`` must be structurally
|
||||
incapable of serving fakes:
|
||||
|
||||
- The per-run model path is the ModelAdapterFactory — a run executes
|
||||
against an ``OllamaModelAdapter`` (resolved per user), never
|
||||
``FakeModelAdapter``. Pinned by asserting the factory resolves the
|
||||
adapter and that the app instance holds no fake in the model slot.
|
||||
- ``auth_enabled=True`` hardwired: every business route 401s without a
|
||||
session; there is no parameter or env that turns auth off.
|
||||
- ``cookie_secure=True``: the login cookie carries Secure (behind TLS).
|
||||
- The data root + secrets file come from env vars (RESEARCHX_DATA_DIR,
|
||||
RESEARCHX_SECRETS_FILE) — the test seam is the natural runtime
|
||||
config, parameterized not env-gated.
|
||||
"""
|
||||
|
||||
import base64
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from researchx.adapters.fakes import FakeModelAdapter
|
||||
from researchx.auth.guard import CSRF_HEADER
|
||||
|
||||
_HEADERS = {CSRF_HEADER: "1"}
|
||||
_KEK = base64.urlsafe_b64encode(os.urandom(32)).decode()
|
||||
|
||||
_ADMIN_EMAIL = "admin@example.org"
|
||||
_ADMIN_PASSWORD = "correct-horse-battery"
|
||||
|
||||
|
||||
def _runtime_env(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch, *, secrets: str | None, data_dir: Path
|
||||
) -> None:
|
||||
"""Point the production entry at test-owned locations.
|
||||
|
||||
This is the parameterization seam: main.py reads the same env vars
|
||||
the container runtime passes (RESEARCHX_SECRETS_FILE via --env-file
|
||||
conventions, RESEARCHX_DATA_DIR for the volume) — no test-only
|
||||
gates exist in the module.
|
||||
"""
|
||||
monkeypatch.setenv("RESEARCHX_DATA_DIR", str(data_dir))
|
||||
if secrets is None:
|
||||
monkeypatch.delenv("RESEARCHX_SECRETS_FILE", raising=False)
|
||||
else:
|
||||
env_file = tmp_path / "env.secrets"
|
||||
env_file.write_text(secrets, encoding="utf-8")
|
||||
monkeypatch.setenv("RESEARCHX_SECRETS_FILE", str(env_file))
|
||||
|
||||
|
||||
def _live_secrets(kek: bool = True, server_key: bool = True) -> str:
|
||||
lines = []
|
||||
if kek:
|
||||
lines.append(f"RESEARCHX_KEK={_KEK}")
|
||||
if server_key:
|
||||
lines.append("OLLAMA_API_KEY=server-tier-key-value")
|
||||
return "\n".join(lines) + "\n"
|
||||
|
||||
|
||||
async def _register_admin(client: httpx.AsyncClient) -> None:
|
||||
await client.post(
|
||||
"/api/auth/register",
|
||||
json={"email": _ADMIN_EMAIL, "password": _ADMIN_PASSWORD, "display_name": "Admin"},
|
||||
headers=_HEADERS,
|
||||
)
|
||||
|
||||
|
||||
async def _login_with_secure_cookie(client: httpx.AsyncClient) -> str:
|
||||
"""Login and return the session cookie header value.
|
||||
|
||||
The production cookie carries Secure (hardwired, E-4); httpx's
|
||||
cookie jar — like a browser over plain http — will not replay a
|
||||
Secure cookie to a non-TLS origin. The tests therefore forward the
|
||||
cookie explicitly: the ASGI transport is the TLS proxy's interior
|
||||
leg, where the Secure flag does not apply.
|
||||
"""
|
||||
login = await client.post(
|
||||
"/api/auth/login",
|
||||
json={"email": _ADMIN_EMAIL, "password": _ADMIN_PASSWORD},
|
||||
headers=_HEADERS,
|
||||
)
|
||||
assert login.status_code == 200
|
||||
return login.headers["set-cookie"].split(";")[0]
|
||||
|
||||
|
||||
def _auth_headers(cookie: str) -> dict[str, str]:
|
||||
return {**_HEADERS, "cookie": cookie}
|
||||
|
||||
|
||||
# --------------------------------------------------------------- structure
|
||||
|
||||
|
||||
async def test_main_factory_resolves_real_adapters(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""The P-3 backend closure: main's factory wiring resolves a real
|
||||
OllamaModelAdapter per user — the production app can never serve
|
||||
FakeModelAdapter (no fixed model adapter is even constructed when
|
||||
a factory is wired; pinned structurally in app.py)."""
|
||||
from researchx.api import main as main_module
|
||||
|
||||
_runtime_env(tmp_path, monkeypatch, secrets=_live_secrets(), data_dir=tmp_path / "data")
|
||||
main_module.build_app() # must import + build cleanly
|
||||
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from researchx.adapters.model_factory import ModelAdapterFactory
|
||||
from researchx.adapters.ollama import OllamaModelAdapter
|
||||
from researchx.adapters.secrets import EnvFileSecretsProvider
|
||||
from researchx.auth.key_store import KeyStore
|
||||
from researchx.auth.models import UserPublic
|
||||
from researchx.auth.settings import UserSettings
|
||||
|
||||
secrets_paths = main_module._secrets_paths()
|
||||
assert secrets_paths, "the runtime env file must be discoverable"
|
||||
secrets = EnvFileSecretsProvider(paths=secrets_paths)
|
||||
factory = ModelAdapterFactory(secrets, KeyStore(secrets, tmp_path / "data"))
|
||||
user = UserPublic(
|
||||
user_id="abcd2345",
|
||||
email="u@example.org",
|
||||
display_name="U",
|
||||
role="user",
|
||||
active=True,
|
||||
created_at=datetime.now(UTC),
|
||||
)
|
||||
adapter, tier = await factory.resolve(user, UserSettings())
|
||||
assert isinstance(adapter, OllamaModelAdapter)
|
||||
assert not isinstance(adapter, FakeModelAdapter)
|
||||
assert tier in ("user", "server", "keyless")
|
||||
|
||||
|
||||
# --------------------------------------------------------------- behavior
|
||||
|
||||
|
||||
async def test_main_app_auth_hardwired(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""No env, no parameter: every business route 401s without a
|
||||
session (REQ-85 — there is no auth_enabled=False path in main)."""
|
||||
from researchx.api import main as main_module
|
||||
|
||||
_runtime_env(tmp_path, monkeypatch, secrets=_live_secrets(), data_dir=tmp_path / "data")
|
||||
app = main_module.build_app()
|
||||
|
||||
async with httpx.AsyncClient(
|
||||
transport=httpx.ASGITransport(app=app), base_url="http://testserver"
|
||||
) as client:
|
||||
for path in [
|
||||
"/api/config",
|
||||
"/api/config/settings",
|
||||
"/api/config/settings/model-key",
|
||||
"/api/methodologies",
|
||||
"/api/runs/abcd2345",
|
||||
]:
|
||||
response = await client.get(path)
|
||||
assert response.status_code == 401, f"{path} leaked without auth"
|
||||
# POST-only surface rejects unauthenticated POSTs identically.
|
||||
assert (await client.post("/api/runs", json={}, headers=_HEADERS)).status_code == 401
|
||||
# The public surface stays public (D-103).
|
||||
assert (await client.get("/healthz")).status_code == 200
|
||||
|
||||
|
||||
async def test_main_app_cookie_secure(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""cookie_secure=True hardwired (E-4): the session cookie carries
|
||||
Secure because the public surface is behind the TLS proxy."""
|
||||
from researchx.api import main as main_module
|
||||
|
||||
data_dir = tmp_path / "data"
|
||||
_runtime_env(tmp_path, monkeypatch, secrets=_live_secrets(), data_dir=data_dir)
|
||||
app = main_module.build_app()
|
||||
|
||||
async with httpx.AsyncClient(
|
||||
transport=httpx.ASGITransport(app=app), base_url="http://testserver"
|
||||
) as client:
|
||||
await _register_admin(client)
|
||||
login = await client.post(
|
||||
"/api/auth/login",
|
||||
json={"email": _ADMIN_EMAIL, "password": _ADMIN_PASSWORD},
|
||||
headers=_HEADERS,
|
||||
)
|
||||
assert login.status_code == 200
|
||||
set_cookie = login.headers["set-cookie"].lower()
|
||||
assert "secure" in set_cookie # hardwired behind TLS
|
||||
|
||||
|
||||
async def test_main_app_config_resolves_tier_with_runtime_env(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""End to end through the production wiring: register + login +
|
||||
GET /api/config shows the resolved tier and key-store state from
|
||||
the runtime env file (KEK + server key -> server tier for a
|
||||
keyless user, key_store enabled)."""
|
||||
from researchx.api import main as main_module
|
||||
|
||||
data_dir = tmp_path / "data"
|
||||
_runtime_env(tmp_path, monkeypatch, secrets=_live_secrets(), data_dir=data_dir)
|
||||
app = main_module.build_app()
|
||||
|
||||
async with httpx.AsyncClient(
|
||||
transport=httpx.ASGITransport(app=app), base_url="http://testserver"
|
||||
) as client:
|
||||
await _register_admin(client)
|
||||
cookie = await _login_with_secure_cookie(client)
|
||||
config = await client.get("/api/config", headers=_auth_headers(cookie))
|
||||
assert config.status_code == 200
|
||||
body = config.json()
|
||||
assert body["resolved_tier"] == "server" # server key present, no user key
|
||||
assert body["key_store"] == "enabled"
|
||||
assert body["model_adapter"] == "OllamaModelAdapter" # never the fake
|
||||
|
||||
|
||||
async def test_main_app_keyless_without_keys(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""No KEK, no server key: key_store disabled, tier keyless — the
|
||||
honest zero-key posture through the production wiring."""
|
||||
from researchx.api import main as main_module
|
||||
|
||||
data_dir = tmp_path / "data"
|
||||
_runtime_env(tmp_path, monkeypatch, secrets="", data_dir=data_dir)
|
||||
app = main_module.build_app()
|
||||
|
||||
async with httpx.AsyncClient(
|
||||
transport=httpx.ASGITransport(app=app), base_url="http://testserver"
|
||||
) as client:
|
||||
await _register_admin(client)
|
||||
cookie = await _login_with_secure_cookie(client)
|
||||
config = await client.get("/api/config", headers=_auth_headers(cookie))
|
||||
body = config.json()
|
||||
assert body["resolved_tier"] == "keyless"
|
||||
assert body["key_store"] == "disabled"
|
||||
assert body["key_status"]["disabled"] is True
|
||||
|
||||
|
||||
async def test_main_app_key_store_round_trip_with_kek(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""With a runtime KEK the production app accepts a user key and
|
||||
resolves tier 'user' afterwards (REQ-73/74 chain end to end)."""
|
||||
from researchx.api import main as main_module
|
||||
|
||||
data_dir = tmp_path / "data"
|
||||
_runtime_env(tmp_path, monkeypatch, secrets=_live_secrets(), data_dir=data_dir)
|
||||
app = main_module.build_app()
|
||||
|
||||
async with httpx.AsyncClient(
|
||||
transport=httpx.ASGITransport(app=app), base_url="http://testserver"
|
||||
) as client:
|
||||
await _register_admin(client)
|
||||
cookie = await _login_with_secure_cookie(client)
|
||||
put = await client.put(
|
||||
"/api/config/settings/model-key",
|
||||
json={"key": "user-tier-key-7777"},
|
||||
headers=_auth_headers(cookie),
|
||||
)
|
||||
assert put.status_code == 200
|
||||
assert put.json()["configured"] is True
|
||||
|
||||
config = await client.get("/api/config", headers=_auth_headers(cookie))
|
||||
body = config.json()
|
||||
assert body["resolved_tier"] == "user"
|
||||
assert body["key_status"]["masked"].endswith("7777")
|
||||
|
||||
# The key file lands under the DATA ROOT (not cwd).
|
||||
me = await client.get("/api/auth/me", headers=_auth_headers(cookie))
|
||||
user_id = me.json()["user_id"]
|
||||
key_file = data_dir / "keys" / f"{user_id}.json"
|
||||
assert key_file.is_file()
|
||||
payload = json.loads(key_file.read_text(encoding="utf-8"))
|
||||
assert "user-tier-key-7777" not in json.dumps(payload) # encrypted at rest
|
||||
|
||||
|
||||
async def test_main_app_storage_is_data_root(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""The config status storage_dir is the data-root string (D-107) —
|
||||
never cwd-dependent."""
|
||||
from researchx.api import main as main_module
|
||||
|
||||
data_dir = tmp_path / "data-root"
|
||||
_runtime_env(tmp_path, monkeypatch, secrets=_live_secrets(), data_dir=data_dir)
|
||||
app = main_module.build_app()
|
||||
|
||||
async with httpx.AsyncClient(
|
||||
transport=httpx.ASGITransport(app=app), base_url="http://testserver"
|
||||
) as client:
|
||||
await _register_admin(client)
|
||||
cookie = await _login_with_secure_cookie(client)
|
||||
config = await client.get("/api/config", headers=_auth_headers(cookie))
|
||||
assert config.json()["storage_dir"] == str(data_dir)
|
||||
|
||||
|
||||
def test_main_module_has_no_auth_disable_surface() -> None:
|
||||
"""The main module exposes no auth_enabled parameter and reads no
|
||||
env var that could disable auth (REQ-85 + C3-16)."""
|
||||
import inspect
|
||||
|
||||
from researchx.api import main as main_module
|
||||
|
||||
signature = inspect.signature(main_module.build_app)
|
||||
assert "auth_enabled" not in signature.parameters
|
||||
assert "cookie_secure" not in signature.parameters
|
||||
source = inspect.getsource(main_module)
|
||||
assert "RESEARCHX_AUTH" not in source
|
||||
assert "auth_enabled=False" not in source
|
||||
# build_app passes auth_enabled=True exactly once (hardcoded).
|
||||
build_source = inspect.getsource(main_module.build_app)
|
||||
assert build_source.count("auth_enabled=") == 1
|
||||
assert "auth_enabled=True" in build_source
|
||||
assert "cookie_secure=True" in build_source
|
||||
@@ -0,0 +1,95 @@
|
||||
import pytest
|
||||
from pydantic import ValidationError
|
||||
|
||||
from researchx.methodologies import registry
|
||||
|
||||
|
||||
def test_registry_resolves_both_slugs() -> None:
|
||||
rapid = registry.get("rapid-answer")
|
||||
deep = registry.get("deep-report")
|
||||
assert rapid.slug == "rapid-answer"
|
||||
assert deep.slug == "deep-report"
|
||||
assert rapid.name == "Rapid Answer"
|
||||
assert deep.name == "Deep Report"
|
||||
|
||||
|
||||
def test_unknown_slug_raises_key_error() -> None:
|
||||
with pytest.raises(KeyError):
|
||||
registry.get("systematic-review")
|
||||
|
||||
|
||||
def test_list_ordered_by_name() -> None:
|
||||
listed = registry.list()
|
||||
assert [m.name for m in listed] == ["Deep Report", "Rapid Answer"]
|
||||
|
||||
|
||||
def test_builtin_dials_match_contracts() -> None:
|
||||
rapid = registry.get("rapid-answer")
|
||||
assert rapid.dials.depth == 1
|
||||
assert rapid.dials.breadth == 1
|
||||
assert rapid.dials.verification_passes == 1
|
||||
assert rapid.dials.citation_rigor == "per-claim"
|
||||
assert rapid.dials.synthesis_style == "memo"
|
||||
assert rapid.dials.output_structure == "brief"
|
||||
assert rapid.output_shape == "memo"
|
||||
|
||||
deep = registry.get("deep-report")
|
||||
assert deep.dials.depth == 3
|
||||
assert deep.dials.breadth == 2
|
||||
assert deep.dials.verification_passes == 2
|
||||
assert deep.dials.citation_rigor == "per-claim"
|
||||
assert deep.dials.synthesis_style == "executive"
|
||||
assert deep.dials.output_structure == "report"
|
||||
assert deep.dials.checkpoints is True
|
||||
assert deep.output_shape == "executive-report"
|
||||
|
||||
|
||||
def test_methodologies_differ_meaningfully() -> None:
|
||||
rapid = registry.get("rapid-answer")
|
||||
deep = registry.get("deep-report")
|
||||
assert rapid.dials.depth != deep.dials.depth
|
||||
assert rapid.dials.breadth != deep.dials.breadth
|
||||
assert rapid.dials.verification_passes != deep.dials.verification_passes
|
||||
assert rapid.dials.synthesis_style != deep.dials.synthesis_style
|
||||
assert rapid.dials.output_structure != deep.dials.output_structure
|
||||
assert rapid.output_shape != deep.output_shape
|
||||
|
||||
|
||||
def test_registry_entries_are_immutable() -> None:
|
||||
snapshot = registry.get("rapid-answer")
|
||||
with pytest.raises(ValidationError):
|
||||
snapshot.dials.depth = 3
|
||||
assert registry.get("rapid-answer").dials.depth == 1
|
||||
|
||||
listed = registry.list()
|
||||
with pytest.raises(ValidationError):
|
||||
listed[0].dials.checkpoints = True
|
||||
assert registry.get("rapid-answer").dials.checkpoints is False
|
||||
|
||||
|
||||
def test_prompt_templates_render_placeholders() -> None:
|
||||
deep = registry.get("deep-report")
|
||||
planner = deep.render_planner_prompt(question="What changed?", context="none")
|
||||
assert "What changed?" in planner
|
||||
assert "none" in planner
|
||||
writer = deep.render_writer_prompt(
|
||||
question="What changed?", context="none", plan="p1", evidence="[p-1] (https://a) quote text"
|
||||
)
|
||||
assert "{plan}" not in writer
|
||||
assert "{evidence}" not in writer
|
||||
assert "p1" in writer
|
||||
assert "[p-1] (https://a) quote text" in writer
|
||||
|
||||
|
||||
def test_writer_prompt_requires_evidence_slot() -> None:
|
||||
for slug in ("rapid-answer", "deep-report"):
|
||||
methodology = registry.get(slug)
|
||||
assert "{evidence}" in methodology.writer_prompt
|
||||
|
||||
|
||||
def test_mutation_of_returned_copy_does_not_affect_registry() -> None:
|
||||
original = registry.get("deep-report").dials.model_dump()
|
||||
copy = registry.get("deep-report").dials.model_copy()
|
||||
mutated = copy.model_copy(update={"depth": 1})
|
||||
assert mutated.depth == 1
|
||||
assert registry.get("deep-report").dials.model_dump() == original
|
||||
@@ -0,0 +1,215 @@
|
||||
import asyncio
|
||||
import json
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from researchx.adapters.fakes import CORPUS, FakeModelAdapter, FakeSearchAdapter
|
||||
from researchx.adapters.local_storage import LocalStorageAdapter
|
||||
from researchx.adapters.secrets import EnvFileSecretsProvider
|
||||
from researchx.adapters.tavily import TavilySearchAdapter
|
||||
from researchx.adapters.types import AdapterError, ChatResult, ModelMessage
|
||||
from researchx.engine.actor import RunActor, make_run_config
|
||||
from researchx.engine.events import EventBus
|
||||
from researchx.engine.run_state import Plan, RunState
|
||||
from researchx.engine.states import RunStateName
|
||||
from researchx.evidence.store import EvidenceStore
|
||||
from researchx.methodologies import registry
|
||||
from researchx.trust.anchoring import anchor_quote, extract_quotes_from_report
|
||||
|
||||
|
||||
class RecordingModel:
|
||||
def __init__(self, canned: list[ChatResult]) -> None:
|
||||
self._canned = list(canned)
|
||||
self.prompts: list[str] = []
|
||||
|
||||
async def chat(
|
||||
self, messages: list[ModelMessage], *, model: str, temperature: float = 0.2,
|
||||
json_mode: bool = False,
|
||||
) -> ChatResult:
|
||||
self.prompts.append(messages[0].content)
|
||||
return self._canned.pop(0)
|
||||
|
||||
async def list_models(self) -> list[str]:
|
||||
return ["fake-model"]
|
||||
|
||||
def roles(self):
|
||||
from researchx.adapters.model import ModelRoles
|
||||
|
||||
return ModelRoles(planner="fake-planner", writer="fake-writer")
|
||||
|
||||
|
||||
_PLAN = json.dumps({"sub_questions": ["verification"], "source_universe": ["web"], "outline": []})
|
||||
|
||||
|
||||
async def test_evidence_reaches_writer_prompt(tmp_path: Path) -> None:
|
||||
_rid, config = make_run_config(
|
||||
question="q", context="", methodology_slug="rapid-answer",
|
||||
overrides={}, registry=registry, run_id="abcd2345",
|
||||
)
|
||||
model = RecordingModel(
|
||||
canned=[
|
||||
ChatResult(content=_PLAN, model="fake-planner"),
|
||||
ChatResult(content="# Report\n\nClaim [p-1].\n", model="fake-writer"),
|
||||
]
|
||||
)
|
||||
actor = RunActor(
|
||||
run_config=config,
|
||||
model=model, # type: ignore[arg-type]
|
||||
search=FakeSearchAdapter(CORPUS),
|
||||
storage=LocalStorageAdapter(base_dir=tmp_path, owner_id="u1a2b3c4"),
|
||||
registry=registry,
|
||||
event_bus=EventBus(),
|
||||
)
|
||||
task = asyncio.create_task(actor.run())
|
||||
while actor.state.state == RunStateName.PLANNING:
|
||||
await asyncio.sleep(0.01)
|
||||
await actor.approve()
|
||||
await task
|
||||
|
||||
writer_prompt = model.prompts[-1]
|
||||
assert "[p-1]" in writer_prompt
|
||||
assert "(https://example.org/" in writer_prompt
|
||||
assert "Evidence passages:" in writer_prompt
|
||||
|
||||
|
||||
async def test_engine_refuses_private_urls_at_extract(tmp_path: Path, monkeypatch) -> None:
|
||||
env_file = tmp_path / ".env.secrets"
|
||||
env_file.write_text("TAVILY_API_KEY=t-key\n", encoding="utf-8")
|
||||
secrets = EnvFileSecretsProvider(paths=[env_file])
|
||||
tavily = TavilySearchAdapter(secrets)
|
||||
|
||||
called_urls: list[str] = []
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
called_urls.append(str(request.url))
|
||||
return httpx.Response(200, text="<html><body>never</body></html>")
|
||||
|
||||
transport = httpx.MockTransport(handler)
|
||||
original = httpx.AsyncClient
|
||||
monkeypatch.setattr(httpx, "AsyncClient", lambda **kw: original(transport=transport, **kw))
|
||||
|
||||
with pytest.raises(AdapterError, match="fetch refused"):
|
||||
await tavily.fetch("http://169.254.169.254/latest/meta-data")
|
||||
assert called_urls == []
|
||||
|
||||
|
||||
async def test_resume_from_searching_mid_run(tmp_path: Path) -> None:
|
||||
_rid, config = make_run_config(
|
||||
question="q", context="", methodology_slug="rapid-answer",
|
||||
overrides={}, registry=registry, run_id="abcd2345",
|
||||
)
|
||||
storage = LocalStorageAdapter(base_dir=tmp_path, owner_id="u1a2b3c4")
|
||||
mid_state = RunState(
|
||||
run_id="abcd2345",
|
||||
question="q",
|
||||
methodology_slug="rapid-answer",
|
||||
state=RunStateName.SEARCHING,
|
||||
plan=Plan(sub_questions=["verification"], source_universe=["web"], outline=[]),
|
||||
depth_cursor=0,
|
||||
depth_target=1,
|
||||
)
|
||||
await storage.write_run_artifact("abcd2345", "state.json", mid_state.model_dump_json())
|
||||
|
||||
model = RecordingModel(
|
||||
canned=[
|
||||
ChatResult(content="# Report\n\nClaim [p-1].\n", model="fake-writer"),
|
||||
]
|
||||
)
|
||||
actor = RunActor(
|
||||
run_config=config,
|
||||
model=model, # type: ignore[arg-type]
|
||||
search=FakeSearchAdapter(CORPUS),
|
||||
storage=storage,
|
||||
registry=registry,
|
||||
event_bus=EventBus(),
|
||||
)
|
||||
actor.state = RunState.model_validate_json(
|
||||
await storage.read_run_artifact("abcd2345", "state.json")
|
||||
)
|
||||
state = await actor.run()
|
||||
assert state.state == RunStateName.DONE
|
||||
|
||||
|
||||
async def test_cancel_after_approval_persists_cancelled(tmp_path: Path) -> None:
|
||||
_rid, config = make_run_config(
|
||||
question="q", context="", methodology_slug="rapid-answer",
|
||||
overrides={}, registry=registry, run_id="abcd2345",
|
||||
)
|
||||
model = FakeModelAdapter(
|
||||
canned=[
|
||||
ChatResult(content=_PLAN, model="fake-planner"),
|
||||
ChatResult(content="# Report\n\nClaim [p-1].\n", model="fake-writer"),
|
||||
]
|
||||
)
|
||||
actor = RunActor(
|
||||
run_config=config,
|
||||
model=model,
|
||||
search=FakeSearchAdapter(CORPUS),
|
||||
storage=LocalStorageAdapter(base_dir=tmp_path, owner_id="u1a2b3c4"),
|
||||
registry=registry,
|
||||
event_bus=EventBus(),
|
||||
)
|
||||
task = asyncio.create_task(actor.run())
|
||||
while actor.state.state == RunStateName.PLANNING:
|
||||
await asyncio.sleep(0.01)
|
||||
await actor.approve()
|
||||
actor.pause()
|
||||
actor.cancel()
|
||||
with pytest.raises(asyncio.CancelledError):
|
||||
await task
|
||||
saved = json.loads(
|
||||
await LocalStorageAdapter(base_dir=tmp_path, owner_id="u1a2b3c4").read_run_artifact("abcd2345", "state.json")
|
||||
)
|
||||
assert saved["state"] == "CANCELLED"
|
||||
|
||||
|
||||
def test_quote_mismatch_flagged_in_ledger_path() -> None:
|
||||
store = EvidenceStore()
|
||||
real_passage = store.add_passage("https://a", "The verified sentence about anchoring " * 5)
|
||||
hallucinated = "This quote was never in any stored passage at all whatsoever."
|
||||
|
||||
verdict = anchor_quote(hallucinated, [real_passage])
|
||||
assert verdict.matched is False
|
||||
assert verdict.weak is False
|
||||
|
||||
good = anchor_quote(real_passage.quote, [real_passage])
|
||||
assert good.matched is True
|
||||
|
||||
report = f"Claim [p-1].\n\n> {hallucinated} [p-1]\n"
|
||||
quotes = extract_quotes_from_report(report, store)
|
||||
assert len(quotes) == 2
|
||||
bad_verdict = anchor_quote(hallucinated, store.writer_context())
|
||||
assert bad_verdict.matched is False
|
||||
|
||||
|
||||
def test_ledger_export_gate() -> None:
|
||||
from researchx.trust.ledger import build_ledger, liveness_export_gate
|
||||
from researchx.trust.liveness import LivenessStatus
|
||||
|
||||
store = EvidenceStore()
|
||||
stored_text = "The verified sentence used for the export gate test."
|
||||
store.add_passage("https://a", stored_text)
|
||||
ledger = build_ledger(
|
||||
run_id="abcd2345",
|
||||
report_text=f"{stored_text} [p-1]",
|
||||
evidence=store,
|
||||
liveness={"https://a": LivenessStatus.LIVE},
|
||||
visited_at=datetime.now(UTC),
|
||||
)
|
||||
passed, failures = liveness_export_gate(ledger)
|
||||
assert passed is True
|
||||
assert failures == []
|
||||
|
||||
ledger_dead = build_ledger(
|
||||
run_id="abcd2345",
|
||||
report_text=f"{stored_text} [p-1]",
|
||||
evidence=store,
|
||||
liveness={"https://a": LivenessStatus.DEAD},
|
||||
visited_at=datetime.now(UTC),
|
||||
)
|
||||
passed, failures = liveness_export_gate(ledger_dead)
|
||||
assert passed is False
|
||||
assert failures == ["https://a"]
|
||||
@@ -0,0 +1,129 @@
|
||||
import asyncio
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from researchx.adapters.capability_probe import probe_model_adapter
|
||||
from researchx.adapters.fakes import CORPUS, FakeModelAdapter, FakeSearchAdapter
|
||||
from researchx.adapters.local_storage import LocalStorageAdapter
|
||||
from researchx.adapters.ollama import OllamaModelAdapter
|
||||
from researchx.adapters.secrets import EnvFileSecretsProvider
|
||||
from researchx.adapters.types import ChatResult
|
||||
from researchx.engine.actor import RunActor, make_run_config
|
||||
from researchx.engine.events import EventBus
|
||||
from researchx.engine.states import RunStateName
|
||||
from researchx.methodologies import registry
|
||||
|
||||
|
||||
async def test_probe_attaches_to_run_state(tmp_path: Path, monkeypatch) -> None:
|
||||
env_file = tmp_path / ".env.secrets"
|
||||
env_file.write_text("OLLAMA_API_KEY=cloud-key\n", encoding="utf-8")
|
||||
secrets = EnvFileSecretsProvider(paths=[env_file])
|
||||
adapter = OllamaModelAdapter(secrets, planner_model="p", writer_model="w")
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
if str(request.url).endswith("/models"):
|
||||
return httpx.Response(200, json={"data": [{"id": "w"}]})
|
||||
return httpx.Response(
|
||||
200, json={"choices": [{"message": {"content": '{"ok": true}'}}]}
|
||||
)
|
||||
|
||||
transport = httpx.MockTransport(handler)
|
||||
original = httpx.AsyncClient
|
||||
monkeypatch.setattr(httpx, "AsyncClient", lambda **kw: original(transport=transport, **kw))
|
||||
|
||||
probe = await probe_model_adapter(adapter)
|
||||
assert probe.structured_outputs_supported is True
|
||||
|
||||
_rid, config = make_run_config(
|
||||
question="q",
|
||||
context="",
|
||||
methodology_slug="rapid-answer",
|
||||
overrides={},
|
||||
registry=registry,
|
||||
run_id="abcd2345",
|
||||
)
|
||||
plan = json.dumps({"sub_questions": ["verification"], "source_universe": ["web"], "outline": []})
|
||||
writer = "# Report\n\nClaim [p-1].\n"
|
||||
fake_model = FakeModelAdapter(
|
||||
canned=[
|
||||
ChatResult(content=plan, model="fake-planner"),
|
||||
ChatResult(content=writer, model="fake-writer"),
|
||||
]
|
||||
)
|
||||
actor = RunActor(
|
||||
run_config=config,
|
||||
model=fake_model,
|
||||
search=FakeSearchAdapter(CORPUS),
|
||||
storage=LocalStorageAdapter(base_dir=tmp_path, owner_id="u1a2b3c4"),
|
||||
registry=registry,
|
||||
event_bus=EventBus(),
|
||||
)
|
||||
await actor.attach_probe(probe.model_dump(mode="json"))
|
||||
assert actor.state.probe is not None
|
||||
assert actor.state.probe["structured_outputs_supported"] is True
|
||||
|
||||
task = asyncio.create_task(actor.run())
|
||||
while actor.state.state == RunStateName.PLANNING:
|
||||
await asyncio.sleep(0.01)
|
||||
await actor.approve()
|
||||
state = await task
|
||||
assert state.state == RunStateName.DONE
|
||||
saved = json.loads(
|
||||
await LocalStorageAdapter(base_dir=tmp_path, owner_id="u1a2b3c4").read_run_artifact("abcd2345", "state.json")
|
||||
)
|
||||
assert saved["probe"]["endpoint"] == "https://ollama.com/v1"
|
||||
|
||||
|
||||
async def test_probe_failure_never_crashes_the_run(tmp_path: Path, monkeypatch) -> None:
|
||||
env_file = tmp_path / ".env.secrets"
|
||||
env_file.write_text("OLLAMA_API_KEY=cloud-key\n", encoding="utf-8")
|
||||
secrets = EnvFileSecretsProvider(paths=[env_file])
|
||||
adapter = OllamaModelAdapter(secrets, planner_model="p", writer_model="w")
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
return httpx.Response(500, text="down")
|
||||
|
||||
transport = httpx.MockTransport(handler)
|
||||
original = httpx.AsyncClient
|
||||
monkeypatch.setattr(httpx, "AsyncClient", lambda **kw: original(transport=transport, **kw))
|
||||
|
||||
from researchx.adapters.types import AdapterError
|
||||
|
||||
with pytest.raises(AdapterError):
|
||||
await probe_model_adapter(adapter)
|
||||
|
||||
_rid, config = make_run_config(
|
||||
question="q",
|
||||
context="",
|
||||
methodology_slug="rapid-answer",
|
||||
overrides={},
|
||||
registry=registry,
|
||||
run_id="abcd2345",
|
||||
)
|
||||
plan = json.dumps({"sub_questions": ["verification"], "source_universe": ["web"], "outline": []})
|
||||
writer = "# Report\n\nClaim [p-1].\n"
|
||||
fake_model = FakeModelAdapter(
|
||||
canned=[
|
||||
ChatResult(content=plan, model="fake-planner"),
|
||||
ChatResult(content=writer, model="fake-writer"),
|
||||
]
|
||||
)
|
||||
bus = EventBus()
|
||||
actor = RunActor(
|
||||
run_config=config,
|
||||
model=fake_model,
|
||||
search=FakeSearchAdapter(CORPUS),
|
||||
storage=LocalStorageAdapter(base_dir=tmp_path, owner_id="u1a2b3c4"),
|
||||
registry=registry,
|
||||
event_bus=bus,
|
||||
)
|
||||
task = asyncio.create_task(actor.run())
|
||||
while actor.state.state == RunStateName.PLANNING:
|
||||
await asyncio.sleep(0.01)
|
||||
await actor.approve()
|
||||
state = await task
|
||||
assert state.state == RunStateName.DONE
|
||||
assert state.probe is None
|
||||
@@ -0,0 +1,203 @@
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from researchx.adapters.duckduckgo import DuckDuckGoSearchAdapter
|
||||
from researchx.adapters.model import ModelRoles
|
||||
from researchx.adapters.ollama import OllamaModelAdapter
|
||||
from researchx.adapters.probe import CapabilityProbe, cap_context_window, structured_output_warning
|
||||
from researchx.adapters.secrets import EnvFileSecretsProvider
|
||||
from researchx.adapters.tavily import TavilySearchAdapter
|
||||
from researchx.adapters.types import AdapterError, ModelMessage
|
||||
|
||||
|
||||
def _secrets(tmp_path, values: dict[str, str]) -> EnvFileSecretsProvider:
|
||||
env_file = tmp_path / ".env.secrets"
|
||||
env_file.write_text(
|
||||
"".join(f"{k}={v}\n" for k, v in values.items()),
|
||||
encoding="utf-8",
|
||||
)
|
||||
return EnvFileSecretsProvider(paths=[env_file])
|
||||
|
||||
|
||||
def _chat_payload_ok() -> dict[str, object]:
|
||||
return {
|
||||
"choices": [{"message": {"role": "assistant", "content": '{"ok": true}'}}],
|
||||
"usage": {"prompt_tokens": 10, "completion_tokens": 5},
|
||||
}
|
||||
|
||||
|
||||
async def test_ollama_adapter_uses_cloud_when_keyed(tmp_path, monkeypatch) -> None:
|
||||
secrets = _secrets(tmp_path, {"OLLAMA_API_KEY": "cloud-key"})
|
||||
adapter = OllamaModelAdapter(secrets, planner_model="p", writer_model="w")
|
||||
|
||||
calls: list[tuple[str, dict[str, str]]] = []
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
calls.append((str(request.url), dict(request.headers)))
|
||||
return httpx.Response(200, json=_chat_payload_ok())
|
||||
|
||||
transport = httpx.MockTransport(handler)
|
||||
original = httpx.AsyncClient
|
||||
monkeypatch.setattr(httpx, "AsyncClient", lambda **kwargs: original(transport=transport, **kwargs))
|
||||
|
||||
result = await adapter.chat([ModelMessage(role="user", content="hi")], model="w")
|
||||
assert result.content == '{"ok": true}'
|
||||
assert calls[0][0].startswith("https://ollama.com/v1")
|
||||
assert "Bearer cloud-key" in calls[0][1]["authorization"]
|
||||
|
||||
|
||||
async def test_ollama_adapter_falls_back_to_local_on_cloud_failure(tmp_path, monkeypatch) -> None:
|
||||
secrets = _secrets(tmp_path, {"OLLAMA_API_KEY": "cloud-key"})
|
||||
adapter = OllamaModelAdapter(secrets, planner_model="p", writer_model="w")
|
||||
|
||||
bases_hit: list[str] = []
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
url = str(request.url)
|
||||
bases_hit.append(url)
|
||||
if url.startswith("https://ollama.com"):
|
||||
return httpx.Response(401, json={"error": "bad key"})
|
||||
return httpx.Response(200, json=_chat_payload_ok())
|
||||
|
||||
transport = httpx.MockTransport(handler)
|
||||
original = httpx.AsyncClient
|
||||
monkeypatch.setattr(httpx, "AsyncClient", lambda **kwargs: original(transport=transport, **kwargs))
|
||||
|
||||
result = await adapter.chat([ModelMessage(role="user", content="hi")], model="w")
|
||||
assert result.content == '{"ok": true}'
|
||||
assert any(u.startswith("http://localhost:11434/v1") for u in bases_hit)
|
||||
assert adapter.last_fallback_reason is not None
|
||||
assert adapter.active_base_url == "http://localhost:11434/v1"
|
||||
|
||||
|
||||
async def test_ollama_roles(tmp_path) -> None:
|
||||
secrets = _secrets(tmp_path, {})
|
||||
adapter = OllamaModelAdapter(secrets, planner_model="planner-model", writer_model="writer-model")
|
||||
assert adapter.roles() == ModelRoles(planner="planner-model", writer="writer-model")
|
||||
|
||||
|
||||
def test_context_cap() -> None:
|
||||
assert cap_context_window(None) == 8192
|
||||
assert cap_context_window(100_000) == 8192
|
||||
assert cap_context_window(4_096) == 4_096
|
||||
assert cap_context_window(0) == 8192
|
||||
|
||||
|
||||
def test_structured_output_warning_contract() -> None:
|
||||
assert structured_output_warning(True, "m") is None
|
||||
warning = structured_output_warning(False, "writer-model")
|
||||
assert warning is not None
|
||||
assert "writer-model" in warning
|
||||
assert "degrade" in warning
|
||||
probe = CapabilityProbe(
|
||||
endpoint="http://localhost:11434/v1",
|
||||
models=["m"],
|
||||
context_window=8192,
|
||||
embedding_batch=8,
|
||||
structured_outputs_supported=False,
|
||||
structured_output_warning=warning,
|
||||
)
|
||||
assert probe.degraded
|
||||
|
||||
|
||||
async def test_tavily_requires_key(tmp_path) -> None:
|
||||
secrets = _secrets(tmp_path, {})
|
||||
with pytest.raises(AdapterError):
|
||||
TavilySearchAdapter(secrets)
|
||||
|
||||
|
||||
async def test_tavily_search_returns_results(tmp_path, monkeypatch) -> None:
|
||||
secrets = _secrets(tmp_path, {"TAVILY_API_KEY": "t-key"})
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
import json
|
||||
|
||||
body = json.loads(request.content.decode())
|
||||
assert body["api_key"] == "t-key"
|
||||
return httpx.Response(
|
||||
200,
|
||||
json={
|
||||
"results": [
|
||||
{"title": "T1", "url": "https://example.org/a", "content": "snippet one"},
|
||||
{"title": "T2", "url": "https://example.org/b", "content": "snippet two"},
|
||||
]
|
||||
},
|
||||
)
|
||||
|
||||
transport = httpx.MockTransport(handler)
|
||||
original = httpx.AsyncClient
|
||||
monkeypatch.setattr(httpx, "AsyncClient", lambda **kwargs: original(transport=transport, **kwargs))
|
||||
|
||||
adapter = TavilySearchAdapter(secrets)
|
||||
results = await adapter.search("deep research", max_results=2)
|
||||
assert [r.title for r in results] == ["T1", "T2"]
|
||||
|
||||
|
||||
async def test_ddg_parses_html_results(monkeypatch) -> None:
|
||||
html = """
|
||||
<div class="results">
|
||||
<a rel="nofollow" class="result__a" href="//duckduckgo.com/l/?uddg=https%3A%2F%2Fexample.org%2Fx&rut=abc">Example One</a>
|
||||
<a class="result__snippet" href="#">First snippet text</a>
|
||||
<a rel="nofollow" class="result__a" href="https://example.org/y">Example Two</a>
|
||||
<a class="result__snippet" href="#">Second snippet text</a>
|
||||
</div>
|
||||
"""
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
return httpx.Response(200, text=html)
|
||||
|
||||
transport = httpx.MockTransport(handler)
|
||||
original = httpx.AsyncClient
|
||||
monkeypatch.setattr(httpx, "AsyncClient", lambda **kwargs: original(transport=transport, **kwargs))
|
||||
|
||||
adapter = DuckDuckGoSearchAdapter()
|
||||
results = await adapter.search("anything", max_results=8)
|
||||
assert [r.url for r in results] == ["https://example.org/x", "https://example.org/y"]
|
||||
assert results[0].snippet == "First snippet text"
|
||||
assert adapter.last_degradation is None
|
||||
|
||||
|
||||
async def test_ddg_rate_limit_degrades_with_typed_warning(monkeypatch) -> None:
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
return httpx.Response(403, text="forbidden")
|
||||
|
||||
transport = httpx.MockTransport(handler)
|
||||
original = httpx.AsyncClient
|
||||
monkeypatch.setattr(httpx, "AsyncClient", lambda **kwargs: original(transport=transport, **kwargs))
|
||||
|
||||
adapter = DuckDuckGoSearchAdapter()
|
||||
results = await adapter.search("anything")
|
||||
assert results == []
|
||||
assert adapter.last_degradation is not None
|
||||
assert "rate-limited" in adapter.last_degradation
|
||||
|
||||
|
||||
async def test_ddg_fetch_extracts_text(monkeypatch) -> None:
|
||||
html = "<html><head><title>Page Title</title></head><body><script>var x=1;</script><p>Hello world</p></body></html>"
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
return httpx.Response(200, text=html)
|
||||
|
||||
transport = httpx.MockTransport(handler)
|
||||
original = httpx.AsyncClient
|
||||
monkeypatch.setattr(httpx, "AsyncClient", lambda **kwargs: original(transport=transport, **kwargs))
|
||||
|
||||
adapter = DuckDuckGoSearchAdapter()
|
||||
page = await adapter.fetch("https://example.org/x")
|
||||
assert page.title == "Page Title"
|
||||
assert "Hello world" in page.text
|
||||
assert "var x" not in page.text
|
||||
|
||||
|
||||
def test_resolve_search_adapter_prefers_tavily(tmp_path) -> None:
|
||||
from researchx.adapters.duckduckgo import resolve_search_adapter
|
||||
|
||||
keyed = _secrets(tmp_path, {"TAVILY_API_KEY": "t-key"})
|
||||
adapter, name = resolve_search_adapter(keyed)
|
||||
assert name == "tavily"
|
||||
assert isinstance(adapter, TavilySearchAdapter)
|
||||
|
||||
keyless = _secrets(tmp_path, {})
|
||||
adapter, name = resolve_search_adapter(keyless)
|
||||
assert name == "duckduckgo-keyless"
|
||||
assert isinstance(adapter, DuckDuckGoSearchAdapter)
|
||||
@@ -0,0 +1,105 @@
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from researchx.adapters.fakes import CORPUS, FakeModelAdapter, FakeSearchAdapter
|
||||
from researchx.adapters.local_storage import LocalStorageAdapter
|
||||
from researchx.adapters.types import ChatResult
|
||||
from researchx.engine.actor import RunActor, make_run_config
|
||||
from researchx.engine.events import EventBus
|
||||
from researchx.engine.run_state import Plan, RunState
|
||||
from researchx.engine.states import RunStateName
|
||||
from researchx.methodologies import registry
|
||||
|
||||
_PLAN = json.dumps(
|
||||
{
|
||||
"sub_questions": ["verification passes"],
|
||||
"source_universe": ["web"],
|
||||
"outline": ["intro"],
|
||||
}
|
||||
)
|
||||
_WRITER = "# Report\n\nClaim [p-1] and claim [p-2].\n"
|
||||
|
||||
|
||||
def test_run_id_generator_is_the_storage_one() -> None:
|
||||
from researchx.engine.actor import make_run_config as mrc
|
||||
|
||||
run_id, _ = mrc(
|
||||
question="q", context="", methodology_slug="rapid-answer", overrides={}, registry=registry
|
||||
)
|
||||
assert len(run_id) == 8
|
||||
alphabet = set("abcdefghjkmnpqrstuvwxyz23456789")
|
||||
assert all(c in alphabet for c in run_id)
|
||||
|
||||
|
||||
async def test_state_file_round_trip_enables_restart(tmp_path: Path) -> None:
|
||||
run_id, config = make_run_config(
|
||||
question="How do engines resume?",
|
||||
context="",
|
||||
methodology_slug="rapid-answer",
|
||||
overrides={},
|
||||
registry=registry,
|
||||
run_id="abcd2345",
|
||||
)
|
||||
storage = LocalStorageAdapter(base_dir=tmp_path, owner_id="u1a2b3c4")
|
||||
state = RunState(
|
||||
run_id=run_id,
|
||||
question=config.question,
|
||||
methodology_slug="rapid-answer",
|
||||
state=RunStateName.SEARCHING,
|
||||
depth_cursor=1,
|
||||
depth_target=3,
|
||||
)
|
||||
await storage.write_run_artifact(run_id, "state.json", state.model_dump_json())
|
||||
|
||||
restored = RunState.model_validate_json(
|
||||
await storage.read_run_artifact(run_id, "state.json")
|
||||
)
|
||||
assert restored.depth_cursor == 1
|
||||
assert restored.depth_target == 3
|
||||
assert restored.state == RunStateName.SEARCHING
|
||||
|
||||
|
||||
async def test_actor_resumes_from_restored_state(tmp_path: Path) -> None:
|
||||
run_id, config = make_run_config(
|
||||
question="How do engines resume?",
|
||||
context="",
|
||||
methodology_slug="rapid-answer",
|
||||
overrides={},
|
||||
registry=registry,
|
||||
run_id="abcd2345",
|
||||
)
|
||||
storage = LocalStorageAdapter(base_dir=tmp_path, owner_id="u1a2b3c4")
|
||||
mid_state = RunState(
|
||||
run_id=run_id,
|
||||
question=config.question,
|
||||
methodology_slug="rapid-answer",
|
||||
state=RunStateName.AWAITING_APPROVAL,
|
||||
depth_cursor=0,
|
||||
depth_target=1,
|
||||
plan=Plan(sub_questions=["verification passes"], source_universe=["web"], outline=["intro"]),
|
||||
)
|
||||
await storage.write_run_artifact(run_id, "state.json", mid_state.model_dump_json())
|
||||
|
||||
model = FakeModelAdapter(
|
||||
canned=[
|
||||
ChatResult(content=_PLAN, model="fake-planner"),
|
||||
ChatResult(content=_WRITER, model="fake-writer"),
|
||||
]
|
||||
)
|
||||
actor = RunActor(
|
||||
run_config=config,
|
||||
model=model,
|
||||
search=FakeSearchAdapter(CORPUS),
|
||||
storage=storage,
|
||||
registry=registry,
|
||||
event_bus=EventBus(),
|
||||
)
|
||||
restored = RunState.model_validate_json(
|
||||
await storage.read_run_artifact(run_id, "state.json")
|
||||
)
|
||||
actor.state = restored
|
||||
await actor.approve()
|
||||
state = await actor.run()
|
||||
assert state.state == RunStateName.DONE
|
||||
assert state.plan is not None
|
||||
assert state.plan.sub_questions == ["verification passes"]
|
||||
@@ -0,0 +1,96 @@
|
||||
from datetime import UTC, datetime
|
||||
|
||||
import pytest
|
||||
from pydantic import ValidationError
|
||||
|
||||
from researchx.config.dials import Dials
|
||||
from researchx.config.run_config import (
|
||||
RunConfig,
|
||||
UnknownOverrideError,
|
||||
new_run_id,
|
||||
resolve_dials,
|
||||
)
|
||||
from researchx.methodologies import registry
|
||||
|
||||
|
||||
def test_resolve_applies_overrides_on_top_of_bundle() -> None:
|
||||
resolved = resolve_dials("deep-report", {"depth": 1, "checkpoints": False}, registry)
|
||||
assert resolved.depth == 1
|
||||
assert resolved.checkpoints is False
|
||||
assert resolved.breadth == 2
|
||||
assert resolved.verification_passes == 2
|
||||
|
||||
|
||||
def test_resolve_empty_overrides_returns_bundle() -> None:
|
||||
resolved = resolve_dials("rapid-answer", {}, registry)
|
||||
assert resolved == registry.get("rapid-answer").dials
|
||||
|
||||
|
||||
def test_resolve_rejects_unknown_override_keys() -> None:
|
||||
with pytest.raises(UnknownOverrideError):
|
||||
resolve_dials("rapid-answer", {"temperature": 0.7}, registry)
|
||||
|
||||
|
||||
def test_resolve_rejects_invalid_override_values() -> None:
|
||||
with pytest.raises(ValidationError):
|
||||
resolve_dials("rapid-answer", {"depth": 9}, registry)
|
||||
with pytest.raises(ValidationError):
|
||||
resolve_dials("deep-report", {"synthesis_style": "poetic"}, registry)
|
||||
|
||||
|
||||
def test_resolve_does_not_mutate_registry_bundle() -> None:
|
||||
resolve_dials("deep-report", {"depth": 1}, registry)
|
||||
assert registry.get("deep-report").dials.depth == 3
|
||||
|
||||
|
||||
def _run_config(**overrides: object) -> RunConfig:
|
||||
return RunConfig(
|
||||
run_id=new_run_id(),
|
||||
methodology_slug="deep-report",
|
||||
question="What changed in the market?",
|
||||
context="Q3 earnings season",
|
||||
resolved_dials=resolve_dials("deep-report", overrides, registry),
|
||||
created_at=datetime.now(UTC),
|
||||
overrides=dict(overrides),
|
||||
)
|
||||
|
||||
|
||||
def test_run_config_accepts_valid_id() -> None:
|
||||
config = _run_config(depth=2)
|
||||
assert config.run_id == "abcd1234" or len(config.run_id) == 8
|
||||
|
||||
|
||||
def test_run_config_rejects_invalid_run_id() -> None:
|
||||
with pytest.raises(ValidationError):
|
||||
RunConfig(
|
||||
run_id="short",
|
||||
methodology_slug="rapid-answer",
|
||||
question="q",
|
||||
resolved_dials=Dials(),
|
||||
created_at=datetime.now(UTC),
|
||||
)
|
||||
|
||||
|
||||
def test_run_config_rejects_empty_question() -> None:
|
||||
with pytest.raises(ValidationError):
|
||||
RunConfig(
|
||||
run_id=new_run_id(),
|
||||
methodology_slug="rapid-answer",
|
||||
question="",
|
||||
resolved_dials=Dials(),
|
||||
created_at=datetime.now(UTC),
|
||||
)
|
||||
|
||||
|
||||
def test_run_config_snapshot_round_trips_json() -> None:
|
||||
config = _run_config(depth=2, checkpoints=False)
|
||||
restored = RunConfig.model_validate_json(config.model_dump_json())
|
||||
assert restored == config
|
||||
assert restored.resolved_dials.depth == 2
|
||||
assert restored.overrides == {"depth": 2, "checkpoints": False}
|
||||
|
||||
|
||||
def test_run_config_is_immutable() -> None:
|
||||
config = _run_config()
|
||||
with pytest.raises(ValidationError):
|
||||
config.question = "different"
|
||||
@@ -0,0 +1,46 @@
|
||||
from pathlib import Path
|
||||
|
||||
from researchx.adapters.secrets import EnvFileSecretsProvider, SecretsProvider
|
||||
|
||||
|
||||
def test_secrets_provider_conformance(tmp_path: Path) -> None:
|
||||
env_file = tmp_path / ".env.secrets"
|
||||
env_file.write_text("OLLAMA_API_KEY=test-key-value\n", encoding="utf-8")
|
||||
provider = EnvFileSecretsProvider(paths=[env_file])
|
||||
assert isinstance(provider, SecretsProvider)
|
||||
assert provider.resolve("OLLAMA_API_KEY") == "test-key-value"
|
||||
assert provider.resolve("MISSING_VAR") is None
|
||||
|
||||
|
||||
def test_secrets_later_file_wins(tmp_path: Path) -> None:
|
||||
first = tmp_path / ".env"
|
||||
second = tmp_path / ".env.secrets"
|
||||
first.write_text("TAVILY_API_KEY=first-key\nOLLAMA_API_KEY=ollama-first\n", encoding="utf-8")
|
||||
second.write_text("OLLAMA_API_KEY=ollama-second\n", encoding="utf-8")
|
||||
provider = EnvFileSecretsProvider(paths=[first, second])
|
||||
assert provider.resolve("TAVILY_API_KEY") == "first-key"
|
||||
assert provider.resolve("OLLAMA_API_KEY") == "ollama-second"
|
||||
|
||||
|
||||
def test_secrets_parse_skips_comments_and_blanks(tmp_path: Path) -> None:
|
||||
env_file = tmp_path / ".env"
|
||||
env_file.write_text(
|
||||
"# comment line\n\nGITEA_TOKEN='quoted-token'\nBROKEN_LINE_NO_EQUALS\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
provider = EnvFileSecretsProvider(paths=[env_file])
|
||||
assert provider.resolve("GITEA_TOKEN") == "quoted-token"
|
||||
assert provider.resolve("BROKEN_LINE_NO_EQUALS") is None
|
||||
|
||||
|
||||
def test_secrets_repr_never_leaks_values(tmp_path: Path) -> None:
|
||||
env_file = tmp_path / ".env.secrets"
|
||||
env_file.write_text("OLLAMA_API_KEY=super-secret-value-123\n", encoding="utf-8")
|
||||
provider = EnvFileSecretsProvider(paths=[env_file])
|
||||
assert "super-secret-value-123" not in repr(provider)
|
||||
assert "super-secret-value-123" not in str(provider)
|
||||
|
||||
|
||||
def test_secrets_missing_file_ignored(tmp_path: Path) -> None:
|
||||
provider = EnvFileSecretsProvider(paths=[tmp_path / ".env.absent"])
|
||||
assert provider.resolve("ANY_KEY") is None
|
||||
@@ -0,0 +1,651 @@
|
||||
"""Settings + model-key routes + factory + config-status suite (STEP 2).
|
||||
|
||||
Covers T-2.4 (settings/key routes), T-2.5 (per-run model factory
|
||||
chain: user → server → keyless), and T-2.6 (config status rework —
|
||||
P-1 closure: factory-resolved probe for the CURRENT user, zero runs
|
||||
required, no cross-user leak).
|
||||
|
||||
Run under the ``auth_enabled=True`` harness (two users prove per-user
|
||||
isolation) plus direct factory tests against fake key stores.
|
||||
"""
|
||||
|
||||
import base64
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from researchx.adapters.fakes import CORPUS, FakeModelAdapter, FakeSearchAdapter
|
||||
from researchx.adapters.model_factory import ModelAdapterFactory
|
||||
from researchx.adapters.secrets import EnvFileSecretsProvider
|
||||
from researchx.adapters.types import ChatResult
|
||||
from researchx.api.app import create_app
|
||||
from researchx.auth.guard import CSRF_HEADER
|
||||
from researchx.auth.key_store import KEK_NAME, KeyStore
|
||||
from researchx.auth.models import UserPublic
|
||||
from researchx.auth.settings import UserSettings
|
||||
|
||||
_PLAN = json.dumps(
|
||||
{"sub_questions": ["verification passes"], "source_universe": ["web"], "outline": ["intro"]}
|
||||
)
|
||||
_WRITER = "# Report\n\nClaim [p-1] and claim [p-2].\n"
|
||||
|
||||
_ADMIN_EMAIL = "admin@example.org"
|
||||
_ADMIN_PASSWORD = "correct-horse-battery"
|
||||
_SECOND_EMAIL = "second@example.org"
|
||||
_SECOND_PASSWORD = "another-long-password"
|
||||
|
||||
_HEADERS = {CSRF_HEADER: "1"}
|
||||
|
||||
_KEK = base64.urlsafe_b64encode(os.urandom(32)).decode()
|
||||
|
||||
_FAKE_KEY = "sk-user-tier-key-9999"
|
||||
|
||||
|
||||
def _env_file(tmp_path: Path, values: dict[str, str]) -> Path:
|
||||
env_file = tmp_path / ".env.secrets"
|
||||
env_file.write_text("".join(f"{k}={v}\n" for k, v in values.items()), encoding="utf-8")
|
||||
return env_file
|
||||
|
||||
|
||||
def _user(user_id: str = "abcd2345") -> UserPublic:
|
||||
from datetime import UTC, datetime
|
||||
|
||||
return UserPublic(
|
||||
user_id=user_id,
|
||||
email="user@example.org",
|
||||
display_name="User",
|
||||
role="user",
|
||||
active=True,
|
||||
created_at=datetime.now(UTC),
|
||||
)
|
||||
|
||||
|
||||
# ------------------------------------------------------------- harness
|
||||
|
||||
|
||||
def _app(
|
||||
tmp_path: Path,
|
||||
*,
|
||||
secrets_values: dict[str, str] | None = None,
|
||||
with_factory: bool = True,
|
||||
):
|
||||
"""create_app under auth_enabled=True with live per-user stores.
|
||||
|
||||
The model adapter stays the zero-key fake (the fake's chat is what
|
||||
runs execute against); the FACTORY is wired so the tier chain and
|
||||
config-status rework are exercised end to end. The factory's bases
|
||||
point at an unroutable host so config-status probes fail fast and
|
||||
hermetically (no outbound HTTP from the unit tier — the probe
|
||||
contract has its own monkeypatched tests below).
|
||||
"""
|
||||
secrets = EnvFileSecretsProvider(
|
||||
paths=[_env_file(tmp_path, secrets_values or {})]
|
||||
)
|
||||
return create_app(
|
||||
storage_dir=tmp_path,
|
||||
model_adapter=FakeModelAdapter(
|
||||
canned=[
|
||||
ChatResult(content=_PLAN, model="fake-planner"),
|
||||
ChatResult(content=_WRITER, model="fake-writer"),
|
||||
]
|
||||
),
|
||||
search_adapter=FakeSearchAdapter(CORPUS),
|
||||
auth_enabled=True,
|
||||
data_dir=tmp_path,
|
||||
secrets_provider=secrets,
|
||||
model_factory=ModelAdapterFactory(
|
||||
secrets,
|
||||
KeyStore(secrets, tmp_path),
|
||||
cloud_base_url="http://127.0.0.1:1/v1",
|
||||
local_base_url="http://127.0.0.1:1/v1",
|
||||
)
|
||||
if with_factory
|
||||
else None,
|
||||
)
|
||||
|
||||
|
||||
async def _client(tmp_path: Path, **kwargs) -> httpx.AsyncClient:
|
||||
return httpx.AsyncClient(
|
||||
transport=httpx.ASGITransport(app=_app(tmp_path, **kwargs)),
|
||||
base_url="http://testserver",
|
||||
)
|
||||
|
||||
|
||||
async def _register_and_login(client: httpx.AsyncClient) -> dict[str, str]:
|
||||
await client.post(
|
||||
"/api/auth/register",
|
||||
json={"email": _ADMIN_EMAIL, "password": _ADMIN_PASSWORD, "display_name": "Admin"},
|
||||
headers=_HEADERS,
|
||||
)
|
||||
login = await client.post(
|
||||
"/api/auth/login", json={"email": _ADMIN_EMAIL, "password": _ADMIN_PASSWORD}, headers=_HEADERS
|
||||
)
|
||||
return login.json()
|
||||
|
||||
|
||||
# --------------------------------------------------- settings CRUD routes
|
||||
|
||||
|
||||
async def test_settings_get_returns_defaults(tmp_path: Path) -> None:
|
||||
async with await _client(tmp_path) as client:
|
||||
me = await _register_and_login(client)
|
||||
response = await client.get("/api/config/settings")
|
||||
assert response.status_code == 200
|
||||
body = response.json()
|
||||
assert body["planner_model"]
|
||||
assert body["writer_model"]
|
||||
assert body["methodology_defaults"] == {}
|
||||
assert body["appearance"] == "system"
|
||||
# never leaks the user's id/email into the settings payload
|
||||
assert "user_id" not in body
|
||||
assert me["email"] not in json.dumps(body)
|
||||
|
||||
|
||||
async def test_settings_put_round_trip(tmp_path: Path) -> None:
|
||||
async with await _client(tmp_path) as client:
|
||||
await _register_and_login(client)
|
||||
put = await client.put(
|
||||
"/api/config/settings",
|
||||
json={"planner_model": "qwen-max", "appearance": "dark"},
|
||||
headers=_HEADERS,
|
||||
)
|
||||
assert put.status_code == 200
|
||||
assert put.json()["planner_model"] == "qwen-max"
|
||||
assert put.json()["appearance"] == "dark"
|
||||
# writer_model untouched -> default preserved
|
||||
assert put.json()["writer_model"]
|
||||
|
||||
got = await client.get("/api/config/settings")
|
||||
assert got.json()["planner_model"] == "qwen-max"
|
||||
assert got.json()["appearance"] == "dark"
|
||||
|
||||
|
||||
async def test_settings_put_partial_preserves(tmp_path: Path) -> None:
|
||||
async with await _client(tmp_path) as client:
|
||||
await _register_and_login(client)
|
||||
await client.put(
|
||||
"/api/config/settings", json={"planner_model": "p-one"}, headers=_HEADERS
|
||||
)
|
||||
put = await client.put(
|
||||
"/api/config/settings", json={"writer_model": "w-two"}, headers=_HEADERS
|
||||
)
|
||||
assert put.json()["planner_model"] == "p-one"
|
||||
assert put.json()["writer_model"] == "w-two"
|
||||
|
||||
|
||||
async def test_settings_put_validates_body(tmp_path: Path) -> None:
|
||||
async with await _client(tmp_path) as client:
|
||||
await _register_and_login(client)
|
||||
response = await client.put(
|
||||
"/api/config/settings", json={"planner_model": ""}, headers=_HEADERS
|
||||
)
|
||||
assert response.status_code == 422
|
||||
|
||||
|
||||
async def test_settings_put_requires_csrf(tmp_path: Path) -> None:
|
||||
async with await _client(tmp_path) as client:
|
||||
await _register_and_login(client)
|
||||
response = await client.put("/api/config/settings", json={"appearance": "dark"})
|
||||
assert response.status_code == 403
|
||||
|
||||
|
||||
async def test_settings_put_explicit_null_treated_as_absent(tmp_path: Path) -> None:
|
||||
"""P2-verify P0 regression: an explicit null field must be treated
|
||||
as absent (partial-merge semantics) — never 200-then-poison the
|
||||
on-disk payload into a corrupt file that 500s every later read."""
|
||||
async with await _client(tmp_path) as client:
|
||||
await _register_and_login(client)
|
||||
put = await client.put(
|
||||
"/api/config/settings", json={"planner_model": None}, headers=_HEADERS
|
||||
)
|
||||
assert put.status_code == 200
|
||||
body = put.json()
|
||||
assert body["planner_model"] # default preserved, not null
|
||||
|
||||
# Every subsequent read + write + run creation stays healthy.
|
||||
got = await client.get("/api/config/settings")
|
||||
assert got.status_code == 200
|
||||
assert got.json()["planner_model"]
|
||||
again = await client.put(
|
||||
"/api/config/settings", json={"planner_model": "qwen-max"}, headers=_HEADERS
|
||||
)
|
||||
assert again.status_code == 200
|
||||
assert again.json()["planner_model"] == "qwen-max"
|
||||
created = await client.post(
|
||||
"/api/runs", json={"question": "q", "methodology": "rapid-answer"}, headers=_HEADERS
|
||||
)
|
||||
assert created.status_code == 201
|
||||
|
||||
|
||||
async def test_settings_isolated_per_user(tmp_path: Path) -> None:
|
||||
app = _app(tmp_path)
|
||||
transport = httpx.ASGITransport(app=app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://testserver") as admin:
|
||||
await _register_and_login(admin)
|
||||
await admin.post(
|
||||
"/api/admin/users",
|
||||
json={"email": _SECOND_EMAIL, "password": _SECOND_PASSWORD, "role": "user"},
|
||||
headers=_HEADERS,
|
||||
)
|
||||
await admin.put(
|
||||
"/api/config/settings", json={"appearance": "dark"}, headers=_HEADERS
|
||||
)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://testserver") as second:
|
||||
await second.post(
|
||||
"/api/auth/login",
|
||||
json={"email": _SECOND_EMAIL, "password": _SECOND_PASSWORD},
|
||||
headers=_HEADERS,
|
||||
)
|
||||
got = await second.get("/api/config/settings")
|
||||
assert got.json()["appearance"] == "system" # no bleed
|
||||
|
||||
|
||||
# --------------------------------------------------- model-key routes
|
||||
|
||||
|
||||
async def test_model_key_put_masked_round_trip(tmp_path: Path) -> None:
|
||||
"""PUT the raw key -> 200 with masked status only; the raw key
|
||||
NEVER appears in any response (REQ-73)."""
|
||||
async with await _client(tmp_path, secrets_values={KEK_NAME: _KEK}) as client:
|
||||
me = await _register_and_login(client)
|
||||
put = await client.put(
|
||||
"/api/config/settings/model-key",
|
||||
json={"key": _FAKE_KEY},
|
||||
headers=_HEADERS,
|
||||
)
|
||||
assert put.status_code == 200
|
||||
body = put.json()
|
||||
assert body["configured"] is True
|
||||
assert body["masked"] is not None
|
||||
assert body["masked"].endswith(_FAKE_KEY[-4:])
|
||||
assert _FAKE_KEY not in put.text
|
||||
|
||||
status = await client.get("/api/config/settings/model-key")
|
||||
assert status.json()["configured"] is True
|
||||
assert _FAKE_KEY not in status.text
|
||||
assert me["user_id"] not in status.text
|
||||
|
||||
|
||||
async def test_model_key_put_disabled_without_kek_honest_409(tmp_path: Path) -> None:
|
||||
"""No KEK -> 409 with the honest {key_store: disabled} payload —
|
||||
never a silent acceptance."""
|
||||
async with await _client(tmp_path) as client: # no KEK in secrets
|
||||
await _register_and_login(client)
|
||||
put = await client.put(
|
||||
"/api/config/settings/model-key",
|
||||
json={"key": "some-key-value"},
|
||||
headers=_HEADERS,
|
||||
)
|
||||
assert put.status_code == 409
|
||||
detail = put.json()["detail"]
|
||||
assert detail["key_store"] == "disabled"
|
||||
assert "RESEARCHX_KEK" in detail["reason"]
|
||||
# Nothing was written to disk.
|
||||
user_dir = tmp_path / "keys"
|
||||
assert not user_dir.exists() or not any(user_dir.iterdir())
|
||||
|
||||
|
||||
async def test_model_key_get_status_disabled_says_so(tmp_path: Path) -> None:
|
||||
async with await _client(tmp_path) as client:
|
||||
await _register_and_login(client)
|
||||
status = await client.get("/api/config/settings/model-key")
|
||||
assert status.status_code == 200
|
||||
body = status.json()
|
||||
assert body["disabled"] is True
|
||||
assert "RESEARCHX_KEK" in body["reason"]
|
||||
assert body["configured"] is False
|
||||
|
||||
|
||||
async def test_model_key_put_requires_csrf(tmp_path: Path) -> None:
|
||||
async with await _client(tmp_path, secrets_values={KEK_NAME: _KEK}) as client:
|
||||
await _register_and_login(client)
|
||||
response = await client.put(
|
||||
"/api/config/settings/model-key", json={"key": "no-header-key"}
|
||||
)
|
||||
assert response.status_code == 403
|
||||
|
||||
|
||||
async def test_model_key_never_in_config_or_settings_responses(tmp_path: Path) -> None:
|
||||
"""The raw key must not surface in ANY authenticated response."""
|
||||
async with await _client(tmp_path, secrets_values={KEK_NAME: _KEK}) as client:
|
||||
await _register_and_login(client)
|
||||
await client.put(
|
||||
"/api/config/settings/model-key", json={"key": _FAKE_KEY}, headers=_HEADERS
|
||||
)
|
||||
for path in [
|
||||
"/api/config",
|
||||
"/api/config/settings",
|
||||
"/api/config/settings/model-key",
|
||||
"/api/auth/me",
|
||||
"/api/methodologies",
|
||||
]:
|
||||
response = await client.get(path)
|
||||
assert _FAKE_KEY not in response.text, f"raw key leaked via {path}"
|
||||
|
||||
|
||||
# --------------------------------------------------------- factory chain
|
||||
|
||||
|
||||
class _FakeKeyStore:
|
||||
"""Test double for KeyStore: enabled with a known key or disabled."""
|
||||
|
||||
def __init__(self, key: str | None = None, enabled: bool = True) -> None:
|
||||
self._key = key
|
||||
self._enabled_flag = enabled
|
||||
|
||||
@property
|
||||
def enabled(self) -> bool:
|
||||
return self._enabled_flag
|
||||
|
||||
async def get_key(self, user_id: str) -> str | None:
|
||||
del user_id
|
||||
return self._key
|
||||
|
||||
async def masked_status(self, user_id: str) -> dict[str, object]:
|
||||
del user_id
|
||||
return {
|
||||
"configured": self._key is not None,
|
||||
"masked": f"…{self._key[-4:]}" if self._key else None,
|
||||
"disabled": not self._enabled_flag,
|
||||
}
|
||||
|
||||
|
||||
def _factory(tmp_path: Path, secrets_values: dict[str, str], key_store: object) -> ModelAdapterFactory:
|
||||
secrets = EnvFileSecretsProvider(paths=[_env_file(tmp_path, secrets_values)])
|
||||
return ModelAdapterFactory(
|
||||
secrets,
|
||||
key_store, # type: ignore[arg-type]
|
||||
cloud_base_url="http://127.0.0.1:1/v1", # unroutable: hermetic unit tier
|
||||
local_base_url="http://127.0.0.1:1/v1",
|
||||
)
|
||||
|
||||
|
||||
async def test_factory_user_tier_when_key_present(tmp_path: Path) -> None:
|
||||
factory = _factory(
|
||||
tmp_path,
|
||||
{"OLLAMA_API_KEY": "server-key-value"},
|
||||
_FakeKeyStore(key=_FAKE_KEY),
|
||||
)
|
||||
adapter, tier = await factory.resolve(_user(), UserSettings())
|
||||
assert tier == "user"
|
||||
assert adapter.roles().planner == UserSettings().planner_model
|
||||
|
||||
|
||||
async def test_factory_server_tier_when_no_user_key(tmp_path: Path) -> None:
|
||||
factory = _factory(
|
||||
tmp_path,
|
||||
{"OLLAMA_API_KEY": "server-key-value"},
|
||||
_FakeKeyStore(key=None),
|
||||
)
|
||||
_adapter, tier = await factory.resolve(_user(), UserSettings())
|
||||
assert tier == "server"
|
||||
|
||||
|
||||
async def test_factory_keyless_when_neither(tmp_path: Path) -> None:
|
||||
factory = _factory(tmp_path, {}, _FakeKeyStore(key=None))
|
||||
_adapter, tier = await factory.resolve(_user(), UserSettings())
|
||||
assert tier == "keyless"
|
||||
|
||||
|
||||
async def test_factory_keyless_when_store_disabled(tmp_path: Path) -> None:
|
||||
factory = _factory(
|
||||
tmp_path,
|
||||
{"OLLAMA_API_KEY": "server-key-value"},
|
||||
_FakeKeyStore(key=None, enabled=False),
|
||||
)
|
||||
_adapter, tier = await factory.resolve(_user(), UserSettings())
|
||||
assert tier == "server" # chain proceeds to server when key store disabled
|
||||
|
||||
|
||||
async def test_factory_respects_user_settings_models(tmp_path: Path) -> None:
|
||||
factory = _factory(tmp_path, {}, _FakeKeyStore(key=None))
|
||||
settings = UserSettings(planner_model="my-planner", writer_model="my-writer")
|
||||
adapter, tier = await factory.resolve(_user(), settings)
|
||||
assert tier == "keyless"
|
||||
assert adapter.roles().planner == "my-planner"
|
||||
assert adapter.roles().writer == "my-writer"
|
||||
|
||||
|
||||
async def test_factory_never_caches_adapters(tmp_path: Path) -> None:
|
||||
factory = _factory(tmp_path, {}, _FakeKeyStore(key=None))
|
||||
first, _ = await factory.resolve(_user(), UserSettings())
|
||||
second, _ = await factory.resolve(_user(), UserSettings())
|
||||
assert first is not second # one instance per run (P-2 closure)
|
||||
|
||||
|
||||
async def test_factory_user_key_precedence_over_server_secrets(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""The resolved user key must be the Authorization the adapter sends
|
||||
— not the server env-file key."""
|
||||
seen_headers: dict[str, str] = {}
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
seen_headers.update(dict(request.headers))
|
||||
return httpx.Response(
|
||||
200, json={"choices": [{"message": {"content": '{"ok": true}'}}]}
|
||||
)
|
||||
|
||||
transport = httpx.MockTransport(handler)
|
||||
original = httpx.AsyncClient
|
||||
monkeypatch.setattr(
|
||||
httpx, "AsyncClient", lambda **kwargs: original(transport=transport, **kwargs)
|
||||
)
|
||||
|
||||
factory = _factory(
|
||||
tmp_path,
|
||||
{"OLLAMA_API_KEY": "server-key-value"},
|
||||
_FakeKeyStore(key=_FAKE_KEY),
|
||||
)
|
||||
adapter, tier = await factory.resolve(_user(), UserSettings())
|
||||
assert tier == "user"
|
||||
await adapter.chat(
|
||||
[__import__("researchx.adapters.types", fromlist=["ModelMessage"]).ModelMessage(
|
||||
role="user", content="hi"
|
||||
)],
|
||||
model="w",
|
||||
)
|
||||
assert seen_headers["authorization"] == f"Bearer {_FAKE_KEY}"
|
||||
|
||||
|
||||
# ------------------------------------------------- config status rework
|
||||
|
||||
|
||||
async def test_config_status_tier_per_user_no_cross_leak(tmp_path: Path) -> None:
|
||||
"""Config status shows the CURRENT user's tier: a keyed user shows
|
||||
'user', a keyless second user shows 'server' — zero runs needed
|
||||
(P-1 closure), zero cross-user bleed."""
|
||||
app = _app(tmp_path, secrets_values={KEK_NAME: _KEK, "OLLAMA_API_KEY": "server-key-value"})
|
||||
transport = httpx.ASGITransport(app=app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://testserver") as admin:
|
||||
await _register_and_login(admin)
|
||||
await admin.put(
|
||||
"/api/config/settings/model-key",
|
||||
json={"key": _FAKE_KEY},
|
||||
headers=_HEADERS,
|
||||
)
|
||||
config = await admin.get("/api/config")
|
||||
assert config.status_code == 200
|
||||
body = config.json()
|
||||
assert body["resolved_tier"] == "user"
|
||||
assert body["key_store"] == "enabled"
|
||||
assert body["key_status"]["configured"] is True
|
||||
assert body["key_status"]["masked"].endswith(_FAKE_KEY[-4:])
|
||||
assert body["cost_counters"] == {"runs_created": 0, "runs_completed": 0}
|
||||
|
||||
await admin.post(
|
||||
"/api/admin/users",
|
||||
json={"email": _SECOND_EMAIL, "password": _SECOND_PASSWORD, "role": "user"},
|
||||
headers=_HEADERS,
|
||||
)
|
||||
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://testserver") as second:
|
||||
await second.post(
|
||||
"/api/auth/login",
|
||||
json={"email": _SECOND_EMAIL, "password": _SECOND_PASSWORD},
|
||||
headers=_HEADERS,
|
||||
)
|
||||
config = await second.get("/api/config")
|
||||
body = config.json()
|
||||
assert body["resolved_tier"] == "server" # no user key of their own
|
||||
assert body["key_store"] == "enabled" # the STORE is enabled; the user has no key
|
||||
assert body["key_status"]["configured"] is False
|
||||
assert _FAKE_KEY not in config.text
|
||||
assert _FAKE_KEY[-4:] not in config.text
|
||||
# Counters owner-keyed: the second user sees zero, not the admin's runs.
|
||||
assert body["cost_counters"] == {"runs_created": 0, "runs_completed": 0}
|
||||
|
||||
|
||||
async def test_config_status_keyless_when_no_keys(tmp_path: Path) -> None:
|
||||
async with await _client(tmp_path) as client:
|
||||
await _register_and_login(client)
|
||||
config = await client.get("/api/config")
|
||||
body = config.json()
|
||||
assert body["resolved_tier"] == "keyless"
|
||||
assert body["key_store"] == "disabled"
|
||||
assert body["key_status"]["disabled"] is True
|
||||
assert body["model_adapter"] == "OllamaModelAdapter" # factory-constructed
|
||||
|
||||
|
||||
async def test_config_status_works_with_zero_runs(tmp_path: Path) -> None:
|
||||
"""P-1: correct config status with zero runs in the registry."""
|
||||
async with await _client(tmp_path) as client:
|
||||
await _register_and_login(client)
|
||||
response = await client.get("/api/config")
|
||||
assert response.status_code == 200
|
||||
assert response.json()["resolved_tier"] in ("user", "server", "keyless")
|
||||
assert response.json()["cost_counters"]["runs_created"] == 0
|
||||
|
||||
|
||||
async def test_config_status_probe_cached_and_error_safe(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""Probe semantics pinned hermetically: a successful probe is
|
||||
cached in-process (second call does not re-probe), and an
|
||||
AdapterError probe yields probe: None (never a 500)."""
|
||||
import researchx.api.app as app_module
|
||||
|
||||
calls: list[int] = []
|
||||
probe_payload = {
|
||||
"endpoint": "http://127.0.0.1:1/v1",
|
||||
"models": ["m"],
|
||||
"context_window": 8192,
|
||||
"embedding_batch": 8,
|
||||
"structured_outputs_supported": True,
|
||||
}
|
||||
from researchx.adapters.probe import CapabilityProbe
|
||||
|
||||
fake_probe = CapabilityProbe(
|
||||
endpoint="http://127.0.0.1:1/v1",
|
||||
models=["m"],
|
||||
context_window=8192,
|
||||
embedding_batch=8,
|
||||
structured_outputs_supported=True,
|
||||
)
|
||||
|
||||
async def _fake_probe(adapter):
|
||||
calls.append(1)
|
||||
return fake_probe
|
||||
|
||||
async def _failing_probe(adapter):
|
||||
from researchx.adapters.types import AdapterError
|
||||
|
||||
raise AdapterError("unreachable")
|
||||
|
||||
monkeypatch.setattr(app_module, "probe_model_adapter", _fake_probe)
|
||||
|
||||
async with await _client(tmp_path) as client:
|
||||
await _register_and_login(client)
|
||||
first = await client.get("/api/config")
|
||||
assert first.status_code == 200
|
||||
assert first.json()["probe"]["endpoint"] == probe_payload["endpoint"]
|
||||
assert len(calls) == 1
|
||||
second = await client.get("/api/config")
|
||||
assert len(calls) == 1 # served from the (user, tier, fingerprint) cache
|
||||
assert second.json()["probe"]["endpoint"] == probe_payload["endpoint"]
|
||||
|
||||
# Fresh app: a failing probe yields None, cached — never a 500, and
|
||||
# the route stays healthy on repeat calls.
|
||||
monkeypatch.setattr(app_module, "probe_model_adapter", _failing_probe)
|
||||
async with await _client(tmp_path) as client:
|
||||
await _register_and_login(client)
|
||||
first = await client.get("/api/config")
|
||||
assert first.status_code == 200
|
||||
assert first.json()["probe"] is None
|
||||
second = await client.get("/api/config")
|
||||
assert second.status_code == 200
|
||||
assert second.json()["probe"] is None
|
||||
|
||||
|
||||
async def test_config_status_uses_factory_adapter_not_fixed_one(tmp_path: Path) -> None:
|
||||
"""The rework: model_adapter names the FACTORY-constructed adapter
|
||||
(OllamaModelAdapter), not the fixed injected fake."""
|
||||
async with await _client(tmp_path) as client:
|
||||
await _register_and_login(client)
|
||||
config = await client.get("/api/config")
|
||||
assert config.json()["model_adapter"] == "OllamaModelAdapter"
|
||||
|
||||
|
||||
async def test_config_status_without_factory_names_fixed_adapter(tmp_path: Path) -> None:
|
||||
"""Back-compat: with no factory wired (pure test harness), the
|
||||
config status still answers with the injected adapter's name."""
|
||||
async with await _client(tmp_path, with_factory=False) as client:
|
||||
await _register_and_login(client)
|
||||
config = await client.get("/api/config")
|
||||
body = config.json()
|
||||
assert body["model_adapter"] == "FakeModelAdapter"
|
||||
assert body["resolved_tier"] == "keyless"
|
||||
|
||||
|
||||
# ----------------------------------------------- create_run tier stamping
|
||||
|
||||
|
||||
async def test_create_run_stamps_credential_tier(tmp_path: Path) -> None:
|
||||
"""create_run resolves the factory and stamps state.credential_tier
|
||||
(REQ-74); the engine contracts are otherwise unchanged."""
|
||||
async with await _client(tmp_path) as client:
|
||||
me = await _register_and_login(client)
|
||||
created = await client.post(
|
||||
"/api/runs", json={"question": "q", "methodology": "rapid-answer"}, headers=_HEADERS
|
||||
)
|
||||
assert created.status_code == 201
|
||||
run_id = created.json()["id"]
|
||||
|
||||
state_path = tmp_path / "runs" / me["user_id"] / run_id / "state.json"
|
||||
import asyncio
|
||||
|
||||
for _ in range(200):
|
||||
if state_path.exists():
|
||||
break
|
||||
await asyncio.sleep(0.01)
|
||||
state = json.loads(state_path.read_text(encoding="utf-8"))
|
||||
assert state["credential_tier"] == "keyless"
|
||||
assert state["owner_id"] == me["user_id"]
|
||||
|
||||
|
||||
async def test_create_run_user_tier_uses_user_key(tmp_path: Path) -> None:
|
||||
"""A keyed user's run executes with THEIR key through the factory
|
||||
chain (fake model still serves the run — zero-key CI)."""
|
||||
async with await _client(tmp_path, secrets_values={KEK_NAME: _KEK}) as client:
|
||||
me = await _register_and_login(client)
|
||||
await client.put(
|
||||
"/api/config/settings/model-key", json={"key": _FAKE_KEY}, headers=_HEADERS
|
||||
)
|
||||
created = await client.post(
|
||||
"/api/runs", json={"question": "q", "methodology": "rapid-answer"}, headers=_HEADERS
|
||||
)
|
||||
assert created.status_code == 201
|
||||
run_id = created.json()["id"]
|
||||
|
||||
import asyncio
|
||||
|
||||
state_path = tmp_path / "runs" / me["user_id"] / run_id / "state.json"
|
||||
for _ in range(200):
|
||||
if state_path.exists():
|
||||
break
|
||||
await asyncio.sleep(0.01)
|
||||
state = json.loads(state_path.read_text(encoding="utf-8"))
|
||||
assert state["credential_tier"] == "user"
|
||||
@@ -0,0 +1,5 @@
|
||||
import researchx
|
||||
|
||||
|
||||
def test_package_imports() -> None:
|
||||
assert researchx.__name__ == "researchx"
|
||||
@@ -0,0 +1,247 @@
|
||||
"""Restart sweep + registry pruning tests (T-1.8, D-115/G-101, P-6)."""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import httpx
|
||||
|
||||
from researchx.adapters.fakes import CORPUS, FakeModelAdapter, FakeSearchAdapter
|
||||
from researchx.adapters.types import ChatResult
|
||||
from researchx.api.app import RunRegistry, create_app
|
||||
from researchx.api.sweep import sweep_in_flight_runs
|
||||
from researchx.auth.guard import CSRF_HEADER
|
||||
from researchx.engine.run_state import RunState
|
||||
from researchx.engine.states import RunStateName, can_transition
|
||||
|
||||
_PLAN = json.dumps(
|
||||
{"sub_questions": ["verification passes"], "source_universe": ["web"], "outline": ["intro"]}
|
||||
)
|
||||
_WRITER = "# Report\n\nClaim [p-1] and claim [p-2].\n"
|
||||
|
||||
_OWNER = "abcd2345" # owner slug used for fixture trees
|
||||
|
||||
|
||||
def _state_file(run_dir: Path) -> Path:
|
||||
run_dir.mkdir(parents=True, exist_ok=True)
|
||||
return run_dir / "state.json"
|
||||
|
||||
|
||||
def _write_state(run_dir: Path, state: RunStateName) -> Path:
|
||||
state_path = _state_file(run_dir)
|
||||
record = RunState(
|
||||
run_id=run_dir.name,
|
||||
question="q",
|
||||
methodology_slug="rapid-answer",
|
||||
state=state,
|
||||
owner_id=_OWNER,
|
||||
)
|
||||
state_path.write_text(record.model_dump_json(), encoding="utf-8")
|
||||
return state_path
|
||||
|
||||
|
||||
# ------------------------------------------------------------------- G-101
|
||||
|
||||
|
||||
def test_planning_to_cancelled_is_now_legal() -> None:
|
||||
"""G-101: the sweep's PLANNING -> CANCELLED rewrite is a legal transition."""
|
||||
assert can_transition(RunStateName.PLANNING, RunStateName.CANCELLED)
|
||||
|
||||
|
||||
def test_sweep_rewrites_planning_fixture_to_cancelled(tmp_path: Path) -> None:
|
||||
base = tmp_path
|
||||
_write_state(base / "runs" / _OWNER / "abcd2345", RunStateName.PLANNING)
|
||||
|
||||
swept = sweep_in_flight_runs(base)
|
||||
assert swept == [(_OWNER, "abcd2345")]
|
||||
|
||||
state = RunState.model_validate_json(
|
||||
(base / "runs" / _OWNER / "abcd2345" / "state.json").read_text(encoding="utf-8")
|
||||
)
|
||||
assert state.state == RunStateName.CANCELLED
|
||||
|
||||
|
||||
def test_sweep_rewrites_every_non_terminal_state(tmp_path: Path) -> None:
|
||||
base = tmp_path
|
||||
non_terminal = [
|
||||
RunStateName.PLANNING,
|
||||
RunStateName.AWAITING_APPROVAL,
|
||||
RunStateName.SEARCHING,
|
||||
RunStateName.EXTRACTING,
|
||||
RunStateName.REFLECTING,
|
||||
RunStateName.SYNTHESIZING,
|
||||
RunStateName.VERIFYING,
|
||||
]
|
||||
for state in non_terminal:
|
||||
_write_state(base / "runs" / _OWNER / f"run{state.value[:4].lower()}", state)
|
||||
|
||||
swept = sweep_in_flight_runs(base)
|
||||
assert len(swept) == len(non_terminal)
|
||||
for _, run_id in swept:
|
||||
record = RunState.model_validate_json(
|
||||
(base / "runs" / _OWNER / run_id / "state.json").read_text(encoding="utf-8")
|
||||
)
|
||||
assert record.state == RunStateName.CANCELLED
|
||||
|
||||
|
||||
def test_sweep_leaves_terminal_states_untouched(tmp_path: Path) -> None:
|
||||
base = tmp_path
|
||||
_write_state(base / "runs" / _OWNER / "done0001", RunStateName.DONE)
|
||||
_write_state(base / "runs" / _OWNER / "fail0001", RunStateName.FAILED)
|
||||
_write_state(base / "runs" / _OWNER / "canc0001", RunStateName.CANCELLED)
|
||||
|
||||
assert sweep_in_flight_runs(base) == []
|
||||
for run_id in ["done0001", "fail0001", "canc0001"]:
|
||||
record = RunState.model_validate_json(
|
||||
(base / "runs" / _OWNER / run_id / "state.json").read_text(encoding="utf-8")
|
||||
)
|
||||
assert record.state in {RunStateName.DONE, RunStateName.FAILED, RunStateName.CANCELLED}
|
||||
|
||||
|
||||
def test_sweep_skips_corrupt_state_and_dotfiles(tmp_path: Path) -> None:
|
||||
base = tmp_path
|
||||
good = _write_state(base / "runs" / _OWNER / "abcd2345", RunStateName.SEARCHING)
|
||||
corrupt = _state_file(base / "runs" / _OWNER / "zzzz9999")
|
||||
corrupt.write_text("{not json", encoding="utf-8")
|
||||
(base / "runs" / _OWNER / ".hidden").mkdir() # dotfile dir skipped
|
||||
(base / "runs" / ".hidden-owner").mkdir() # dotfile owner skipped
|
||||
|
||||
swept = sweep_in_flight_runs(base)
|
||||
assert swept == [(_OWNER, "abcd2345")]
|
||||
assert corrupt.read_text(encoding="utf-8") == "{not json" # untouched
|
||||
del good
|
||||
|
||||
|
||||
def test_sweep_on_empty_base_dir_is_noop(tmp_path: Path) -> None:
|
||||
assert sweep_in_flight_runs(tmp_path) == []
|
||||
assert sweep_in_flight_runs(tmp_path / "missing") == []
|
||||
|
||||
|
||||
def test_create_app_sweeps_on_construction(tmp_path: Path) -> None:
|
||||
"""The PLANNING fixture -> create_app -> state is CANCELLED on disk."""
|
||||
_write_state(tmp_path / "runs" / _OWNER / "abcd2345", RunStateName.PLANNING)
|
||||
|
||||
create_app(
|
||||
storage_dir=tmp_path,
|
||||
model_adapter=FakeModelAdapter(
|
||||
canned=[ChatResult(content=_PLAN, model="fake-planner")]
|
||||
),
|
||||
search_adapter=FakeSearchAdapter(CORPUS),
|
||||
auth_enabled=False,
|
||||
data_dir=tmp_path,
|
||||
)
|
||||
|
||||
state = RunState.model_validate_json(
|
||||
(tmp_path / "runs" / _OWNER / "abcd2345" / "state.json").read_text(encoding="utf-8")
|
||||
)
|
||||
assert state.state == RunStateName.CANCELLED
|
||||
|
||||
|
||||
# --------------------------------------------------------------------- P-6
|
||||
|
||||
|
||||
async def test_registry_prune_drops_terminal_run_with_zero_subscribers() -> None:
|
||||
registry = RunRegistry()
|
||||
assert registry.prune("abcd2345", "efgh6789") is False # unknown entry: no-op
|
||||
|
||||
# Build a fake terminal entry.
|
||||
from researchx.engine.events import EventBus
|
||||
|
||||
bus = EventBus()
|
||||
|
||||
class _FakeActor:
|
||||
pass
|
||||
|
||||
class _FakeState:
|
||||
state = RunStateName.DONE
|
||||
|
||||
fake_actor = _FakeActor()
|
||||
fake_actor.state = _FakeState() # type: ignore[assignment]
|
||||
|
||||
async def _noop() -> RunState:
|
||||
raise NotImplementedError
|
||||
|
||||
task: object = asyncio.Future() # placeholder, never awaited
|
||||
registry.add("abcd2345", "efgh6789", fake_actor, task, bus) # type: ignore[arg-type]
|
||||
assert registry.has("abcd2345", "efgh6789")
|
||||
|
||||
# A live subscriber blocks pruning.
|
||||
registry.subscribe_events("abcd2345", "efgh6789")
|
||||
assert registry.prune("abcd2345", "efgh6789") is False
|
||||
assert registry.has("abcd2345", "efgh6789")
|
||||
|
||||
# Last subscriber leaves -> terminal run pruned.
|
||||
registry.unsubscribe_events("abcd2345", "efgh6789")
|
||||
assert registry.prune("abcd2345", "efgh6789") is True
|
||||
assert not registry.has("abcd2345", "efgh6789")
|
||||
assert registry.prune("abcd2345", "efgh6789") is False # already gone
|
||||
|
||||
|
||||
def test_registry_refcounts_multiple_subscribers() -> None:
|
||||
registry = RunRegistry()
|
||||
registry.subscribe_events("abcd2345", "efgh6789")
|
||||
registry.subscribe_events("abcd2345", "efgh6789")
|
||||
assert registry.subscriber_count("abcd2345", "efgh6789") == 2
|
||||
registry.unsubscribe_events("abcd2345", "efgh6789")
|
||||
assert registry.subscriber_count("abcd2345", "efgh6789") == 1
|
||||
registry.unsubscribe_events("abcd2345", "efgh6789")
|
||||
assert registry.subscriber_count("abcd2345", "efgh6789") == 0
|
||||
registry.unsubscribe_events("abcd2345", "efgh6789") # underflow-safe
|
||||
assert registry.subscriber_count("abcd2345", "efgh6789") == 0
|
||||
|
||||
|
||||
async def test_events_for_pruned_run_404(tmp_path: Path) -> None:
|
||||
"""Terminal run + all subscribers gone -> registry drops it; the
|
||||
events endpoint 404s afterwards (documented acceptable — the
|
||||
trace persists on disk, D-115/P-6)."""
|
||||
app = create_app(
|
||||
storage_dir=tmp_path,
|
||||
model_adapter=FakeModelAdapter(
|
||||
canned=[
|
||||
ChatResult(content=_PLAN, model="fake-planner"),
|
||||
ChatResult(content=_WRITER, model="fake-writer"),
|
||||
]
|
||||
),
|
||||
search_adapter=FakeSearchAdapter(CORPUS),
|
||||
auth_enabled=False,
|
||||
data_dir=tmp_path,
|
||||
)
|
||||
async with httpx.AsyncClient(
|
||||
transport=httpx.ASGITransport(app=app), base_url="http://testserver"
|
||||
) as client:
|
||||
created = await client.post(
|
||||
"/api/runs", json={"question": "q", "methodology": "rapid-answer"}
|
||||
)
|
||||
run_id = created.json()["id"]
|
||||
|
||||
for _ in range(200):
|
||||
status = (await client.get(f"/api/runs/{run_id}")).json()["status"]
|
||||
if status == "awaiting_approval":
|
||||
break
|
||||
await asyncio.sleep(0.01)
|
||||
await client.post(f"/api/runs/{run_id}/approve", headers={CSRF_HEADER: "1"})
|
||||
for _ in range(200):
|
||||
status = (await client.get(f"/api/runs/{run_id}")).json()["status"]
|
||||
if status == "done":
|
||||
break
|
||||
await asyncio.sleep(0.01)
|
||||
|
||||
# Consume the full SSE replay; on stream end the run prunes.
|
||||
collected = b""
|
||||
async with client.stream("GET", f"/api/runs/{run_id}/events") as response:
|
||||
assert response.status_code == 200
|
||||
async for chunk in response.aiter_bytes():
|
||||
collected += chunk
|
||||
if b"run_done" in collected:
|
||||
break
|
||||
await asyncio.sleep(0.1) # let the finally-block prune
|
||||
|
||||
# The report still exists on disk...
|
||||
report = await client.get(f"/api/runs/{run_id}/report")
|
||||
assert report.status_code in (200, 404) # read-model parity lands in P3
|
||||
state_path = list((tmp_path / "runs").glob(f"*/{run_id}/state.json"))
|
||||
assert state_path, "trace/state persists on disk after prune"
|
||||
|
||||
# ...and a second events request for the pruned run 404s (the
|
||||
# documented post-prune behavior).
|
||||
assert (await client.get(f"/api/runs/{run_id}/events")).status_code == 404
|
||||
@@ -0,0 +1,159 @@
|
||||
from datetime import UTC, datetime
|
||||
|
||||
import httpx
|
||||
|
||||
from researchx.adapters.fakes import CORPUS, SHARED_SENTENCE
|
||||
from researchx.evidence.store import EvidenceStore
|
||||
from researchx.trust.anchoring import anchor_quote, normalize
|
||||
from researchx.trust.ledger import build_ledger, export_checklist
|
||||
from researchx.trust.liveness import LivenessStatus, check_liveness, liveness_report, url_allowed
|
||||
|
||||
|
||||
def test_normalize_pipeline() -> None:
|
||||
assert normalize(" Foo Bar ") == "foo bar"
|
||||
assert normalize("Café") == normalize("café")
|
||||
|
||||
|
||||
def test_anchor_exact_substring_short_circuits() -> None:
|
||||
store = EvidenceStore()
|
||||
passage = store.add_passage("https://a", SHARED_SENTENCE)
|
||||
verdict = anchor_quote(SHARED_SENTENCE, [passage])
|
||||
assert verdict.matched is True
|
||||
assert verdict.ratio == 1.0
|
||||
assert verdict.weak is False
|
||||
|
||||
|
||||
def test_anchor_weak_match_flagged() -> None:
|
||||
store = EvidenceStore()
|
||||
passage = store.add_passage("https://a", SHARED_SENTENCE)
|
||||
mutated = SHARED_SENTENCE.replace(
|
||||
"The verification pass re-anchored every quoted claim",
|
||||
"A liveness check validated the cited evidence",
|
||||
)
|
||||
verdict = anchor_quote(mutated, [passage])
|
||||
assert verdict.ratio < 0.85
|
||||
|
||||
|
||||
def test_anchor_high_fuzzy_match() -> None:
|
||||
store = EvidenceStore()
|
||||
passage = store.add_passage("https://a", "The pipeline verifies every quoted claim against its stored passage before shipping.")
|
||||
near = "The pipeline verify every quoted claim against its stored passage before shipping."
|
||||
verdict = anchor_quote(near, [passage])
|
||||
assert verdict.ratio >= 0.85
|
||||
assert verdict.matched is True
|
||||
|
||||
|
||||
def test_anchor_no_match_returns_unmatched() -> None:
|
||||
store = EvidenceStore()
|
||||
passage = store.add_passage("https://a", SHARED_SENTENCE)
|
||||
verdict = anchor_quote("completely unrelated text about something else entirely", [passage])
|
||||
assert verdict.matched is False
|
||||
assert verdict.weak is False
|
||||
assert verdict.passage_id is None
|
||||
|
||||
|
||||
def test_url_guard_blocks_private_and_schemes() -> None:
|
||||
assert url_allowed("http://169.254.169.254/latest/meta-data") is False
|
||||
assert url_allowed("http://localhost:8000/api") is False
|
||||
assert url_allowed("http://127.0.0.1/x") is False
|
||||
assert url_allowed("http://10.0.0.1/x") is False
|
||||
assert url_allowed("file:///etc/passwd") is False
|
||||
assert url_allowed("ftp://example.org/x") is False
|
||||
assert url_allowed("not a url") is False
|
||||
|
||||
|
||||
async def test_liveness_classification_live(monkeypatch) -> None:
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
return httpx.Response(200, text="alive page")
|
||||
|
||||
transport = httpx.MockTransport(handler)
|
||||
async with httpx.AsyncClient(transport=transport, follow_redirects=True) as client:
|
||||
status = await check_liveness("https://example.org/live", client)
|
||||
assert status == LivenessStatus.LIVE
|
||||
|
||||
|
||||
async def test_liveness_dead_on_error(monkeypatch) -> None:
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
return httpx.Response(500, text="boom")
|
||||
|
||||
transport = httpx.MockTransport(handler)
|
||||
async with httpx.AsyncClient(transport=transport) as client:
|
||||
status = await check_liveness("https://example.org/dead", client)
|
||||
assert status == LivenessStatus.DEAD
|
||||
|
||||
|
||||
async def test_liveness_report_dedupes_and_covers() -> None:
|
||||
report = await liveness_report(["https://a", "https://a", "https://b"])
|
||||
assert report["https://a"] == LivenessStatus.DEAD
|
||||
assert len(report) == 2
|
||||
|
||||
|
||||
async def test_liveness_blocked_url_reports_dead() -> None:
|
||||
status = await check_liveness("http://169.254.169.254/latest/meta-data")
|
||||
assert status == LivenessStatus.DEAD
|
||||
|
||||
|
||||
def _store_with_report() -> tuple[EvidenceStore, str]:
|
||||
store = EvidenceStore()
|
||||
for page in CORPUS[:3]:
|
||||
store.add_page(page.url, page.title, page.text)
|
||||
first_passage = store.pages()[0].passage_ids[0]
|
||||
stored = store.get(first_passage)
|
||||
sentence = (stored.text if stored else "").split(".")[0] + "."
|
||||
report = f"# Report\n\n{sentence} [{first_passage}]\n\nMore context [{first_passage}]\n"
|
||||
return store, report
|
||||
|
||||
|
||||
async def test_ledger_build_and_checklist_export(monkeypatch) -> None:
|
||||
store, report = _store_with_report()
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
return httpx.Response(200, text="ok")
|
||||
|
||||
transport = httpx.MockTransport(handler)
|
||||
async with httpx.AsyncClient(transport=transport, follow_redirects=True) as client:
|
||||
liveness = {}
|
||||
for page in store.pages():
|
||||
liveness[page.url] = await check_liveness(page.url, client)
|
||||
|
||||
ledger = build_ledger(
|
||||
run_id="abcd2345",
|
||||
report_text=report,
|
||||
evidence=store,
|
||||
liveness=liveness,
|
||||
visited_at=datetime.now(UTC),
|
||||
)
|
||||
assert ledger.run_id == "abcd2345"
|
||||
cited_url = store.pages()[0].url
|
||||
assert [e.url for e in ledger.entries] == [cited_url]
|
||||
assert ledger.checklist.citations_checked == 1
|
||||
assert ledger.checklist.live == 1
|
||||
assert ledger.checklist.dead == 0
|
||||
assert ledger.checklist.anchored_claims >= 1
|
||||
assert all(
|
||||
entry.authority_tier in ("reference", "official", "academic", "general")
|
||||
for entry in ledger.entries
|
||||
)
|
||||
|
||||
checklist = export_checklist(ledger)
|
||||
assert "Verification Checklist" in checklist
|
||||
assert "Live:" in checklist
|
||||
assert "Weak matches" in checklist
|
||||
|
||||
|
||||
def test_ledger_confidence_labels_single_vs_corroborated() -> None:
|
||||
store = EvidenceStore()
|
||||
store.add_passage("https://a", SHARED_SENTENCE)
|
||||
store.add_passage("https://b", SHARED_SENTENCE)
|
||||
report = "[p-1] [p-2]"
|
||||
ledger = build_ledger(
|
||||
run_id="abcd2345",
|
||||
report_text=report,
|
||||
evidence=store,
|
||||
liveness={"https://a": LivenessStatus.LIVE, "https://b": LivenessStatus.LIVE},
|
||||
visited_at=datetime.now(UTC),
|
||||
)
|
||||
labels = ledger.checklist.confidence_labels
|
||||
assert len(labels) == 2
|
||||
corroborated = [v for v in labels.values() if v == "corroborated"]
|
||||
assert len(corroborated) >= 1
|
||||
@@ -0,0 +1,263 @@
|
||||
"""Store-level tests for the auth package (T-1.1/T-1.2/T-1.3, REQ-69)."""
|
||||
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from researchx.auth.sessions import SESSION_TTL_SECONDS, Session, SessionStore
|
||||
from researchx.auth.users import MIN_PASSWORD_LENGTH, UserError, UserStore
|
||||
|
||||
_EMAIL = "owner@example.org"
|
||||
_PASSWORD = "correct-horse-battery"
|
||||
|
||||
|
||||
def _store(tmp_path: Path) -> UserStore:
|
||||
return UserStore(tmp_path / "users.json")
|
||||
|
||||
|
||||
def _sessions(tmp_path: Path) -> SessionStore:
|
||||
return SessionStore(tmp_path / "sessions.json")
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ users
|
||||
|
||||
|
||||
async def test_user_store_create_get_list(tmp_path: Path) -> None:
|
||||
store = _store(tmp_path)
|
||||
assert store.empty() is True
|
||||
|
||||
user = await store.create_user(
|
||||
email=_EMAIL, password=_PASSWORD, display_name="Owner", role="admin"
|
||||
)
|
||||
assert store.empty() is False
|
||||
assert user.role == "admin"
|
||||
assert user.active is True
|
||||
assert len(user.user_id) == 8
|
||||
assert user.user_id.isascii() and user.user_id.islower()
|
||||
|
||||
fetched = store.get(user.user_id)
|
||||
assert fetched is not None and fetched.email == _EMAIL
|
||||
by_email = store.get_by_email("Owner@Example.ORG")
|
||||
assert by_email is not None and by_email.user_id == user.user_id
|
||||
|
||||
listed = store.list()
|
||||
assert [u.user_id for u in listed] == [user.user_id]
|
||||
assert store.get("zzzz9999") is None
|
||||
|
||||
|
||||
async def test_password_policy_min_12_enforced(tmp_path: Path) -> None:
|
||||
store = _store(tmp_path)
|
||||
with pytest.raises(UserError, match="at least 12"):
|
||||
await store.create_user(
|
||||
email=_EMAIL, password="short", display_name="Owner"
|
||||
)
|
||||
assert len("short") < MIN_PASSWORD_LENGTH
|
||||
assert store.empty() is True # nothing inserted
|
||||
|
||||
with pytest.raises(UserError):
|
||||
await store.create_user(
|
||||
email=_EMAIL, password="a" * (MIN_PASSWORD_LENGTH - 1), display_name="Owner"
|
||||
)
|
||||
assert store.empty() is True
|
||||
|
||||
ok = await store.create_user(
|
||||
email=_EMAIL, password="a" * MIN_PASSWORD_LENGTH, display_name="Owner"
|
||||
)
|
||||
assert ok.user_id
|
||||
|
||||
|
||||
async def test_duplicate_email_rejected(tmp_path: Path) -> None:
|
||||
store = _store(tmp_path)
|
||||
await store.create_user(email=_EMAIL, password=_PASSWORD, display_name="A")
|
||||
with pytest.raises(UserError, match="already registered"):
|
||||
await store.create_user(
|
||||
email="OWNER@example.org", password=_PASSWORD, display_name="B"
|
||||
)
|
||||
assert len(store.list()) == 1
|
||||
|
||||
|
||||
async def test_argon2_hash_verify_round_trip(tmp_path: Path) -> None:
|
||||
store = _store(tmp_path)
|
||||
user = await store.create_user(
|
||||
email=_EMAIL, password=_PASSWORD, display_name="Owner"
|
||||
)
|
||||
assert user.password_hash != _PASSWORD
|
||||
assert user.password_hash.startswith("$argon2")
|
||||
|
||||
assert await store.verify_password(user, _PASSWORD) is True
|
||||
wrong = user.model_copy(update={"password_hash": user.password_hash})
|
||||
assert await store.verify_password(wrong, "wrong-password-entirely") is False
|
||||
|
||||
other = await _store(tmp_path).create_user(
|
||||
email="b@example.org", password="another-password-12", display_name="B"
|
||||
)
|
||||
assert other.password_hash != user.password_hash # unique salts
|
||||
|
||||
|
||||
async def test_user_store_persists_across_reload(tmp_path: Path) -> None:
|
||||
path = tmp_path / "users.json"
|
||||
store = UserStore(path)
|
||||
user = await store.create_user(
|
||||
email=_EMAIL, password=_PASSWORD, display_name="Owner", role="admin"
|
||||
)
|
||||
assert path.exists()
|
||||
|
||||
reloaded = UserStore(path)
|
||||
fetched = reloaded.get(user.user_id)
|
||||
assert fetched is not None
|
||||
assert fetched.email == _EMAIL
|
||||
assert await reloaded.verify_password(fetched, _PASSWORD) # hash survives the round-trip
|
||||
assert reloaded.get_by_email("OWNER@EXAMPLE.ORG") is not None
|
||||
|
||||
|
||||
async def test_user_store_deactivate(tmp_path: Path) -> None:
|
||||
store = _store(tmp_path)
|
||||
user = await store.create_user(
|
||||
email=_EMAIL, password=_PASSWORD, display_name="Owner"
|
||||
)
|
||||
updated = store.deactivate(user.user_id)
|
||||
assert updated is not None and updated.active is False
|
||||
assert store.get(user.user_id).active is False
|
||||
assert store.deactivate("zzzz9999") is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- sessions
|
||||
|
||||
|
||||
async def test_session_create_get_round_trip(tmp_path: Path) -> None:
|
||||
store = _store(tmp_path)
|
||||
user = await store.create_user(email=_EMAIL, password=_PASSWORD, display_name="Owner")
|
||||
sessions = _sessions(tmp_path)
|
||||
|
||||
token = sessions.generate_token()
|
||||
session = sessions.create(token, user.user_id)
|
||||
assert session.token_hash != token # hash-only storage
|
||||
assert len(session.token_hash) == 64 # sha256 hex
|
||||
|
||||
resolved = sessions.get(token)
|
||||
assert resolved is not None
|
||||
assert resolved.user_id == user.user_id
|
||||
assert sessions.get("not-a-real-token") is None
|
||||
|
||||
|
||||
async def test_session_expiry(tmp_path: Path) -> None:
|
||||
sessions = _sessions(tmp_path)
|
||||
token = sessions.generate_token()
|
||||
session = sessions.create(token, "abcd2345")
|
||||
assert session.expires_at > datetime.now(UTC)
|
||||
|
||||
# Expired sessions resolve to None and are evicted.
|
||||
expired = Session(
|
||||
token_hash=session.token_hash,
|
||||
user_id="abcd2345",
|
||||
created_at=datetime.now(UTC) - timedelta(days=15),
|
||||
last_seen=datetime.now(UTC) - timedelta(days=15),
|
||||
expires_at=datetime.now(UTC) - timedelta(seconds=1),
|
||||
)
|
||||
sessions._sessions[session.token_hash] = expired
|
||||
assert sessions.get(token) is None
|
||||
assert session.token_hash not in sessions._sessions
|
||||
|
||||
|
||||
async def test_session_sliding_touch(tmp_path: Path) -> None:
|
||||
sessions = _sessions(tmp_path)
|
||||
token = sessions.generate_token()
|
||||
session = sessions.create(token, "abcd2345")
|
||||
original_expiry = session.expires_at
|
||||
|
||||
old_seen = datetime.now(UTC) - timedelta(days=10)
|
||||
stale = session.model_copy(
|
||||
update={"last_seen": old_seen, "expires_at": old_seen + timedelta(seconds=1)}
|
||||
)
|
||||
sessions._sessions[stale.token_hash] = stale
|
||||
sessions.touch(stale)
|
||||
|
||||
renewed = sessions.get(token)
|
||||
assert renewed is not None
|
||||
assert renewed.expires_at > original_expiry - timedelta(days=10)
|
||||
assert renewed.last_seen > old_seen
|
||||
assert renewed.expires_at - renewed.last_seen == timedelta(seconds=SESSION_TTL_SECONDS)
|
||||
|
||||
|
||||
def test_session_touch_persist_throttle(tmp_path: Path) -> None:
|
||||
sessions = _sessions(tmp_path)
|
||||
token = sessions.generate_token()
|
||||
session = sessions.create(token, "abcd2345")
|
||||
path = sessions._path
|
||||
|
||||
first_write = path.stat().st_mtime_ns
|
||||
sessions.touch(session) # within 60s of the create persist → throttled
|
||||
assert path.stat().st_mtime_ns == first_write
|
||||
|
||||
sessions._last_persist -= 61.0 # simulate the throttle window elapsing
|
||||
session = sessions.get(token)
|
||||
assert session is not None
|
||||
sessions.touch(session)
|
||||
assert path.stat().st_mtime_ns != first_write
|
||||
|
||||
|
||||
async def test_session_revoke_semantics(tmp_path: Path) -> None:
|
||||
sessions = _sessions(tmp_path)
|
||||
token_a = sessions.generate_token()
|
||||
token_b = sessions.generate_token()
|
||||
token_c = sessions.generate_token()
|
||||
sessions.create(token_a, "abcd2345")
|
||||
sessions.create(token_b, "abcd2345")
|
||||
sessions.create(token_c, "efgh6789")
|
||||
|
||||
sessions.revoke(token_a)
|
||||
assert sessions.get(token_a) is None
|
||||
assert sessions.get(token_b) is not None # siblings survive single revoke
|
||||
|
||||
sessions.revoke_all_for("abcd2345")
|
||||
assert sessions.get(token_b) is None
|
||||
assert sessions.get(token_c) is not None # other users untouched
|
||||
|
||||
sessions.revoke("unknown-token") # idempotent
|
||||
sessions.revoke_all_for("zzzz9999")
|
||||
|
||||
|
||||
async def test_session_store_persists_across_reload(tmp_path: Path) -> None:
|
||||
path = tmp_path / "sessions.json"
|
||||
sessions = SessionStore(path)
|
||||
token = sessions.generate_token()
|
||||
created = sessions.create(token, "abcd2345")
|
||||
del created
|
||||
|
||||
reloaded = SessionStore(path)
|
||||
assert reloaded.get(token) is not None
|
||||
reloaded.revoke(token)
|
||||
assert SessionStore(path).get(token) is None # revocation persisted too
|
||||
|
||||
|
||||
# ------------------------------------------------------- corruption posture
|
||||
|
||||
|
||||
def test_corrupt_user_store_fails_closed(tmp_path: Path) -> None:
|
||||
"""A corrupt users.json must NOT silently become an empty store:
|
||||
swallowing it would reopen registration on top of existing
|
||||
accounts and the next persist would overwrite them. The store
|
||||
must refuse to start (file moved aside for operator recovery)."""
|
||||
path = tmp_path / "users.json"
|
||||
path.write_text("{not json", encoding="utf-8")
|
||||
|
||||
with pytest.raises(RuntimeError, match="corrupt user store"):
|
||||
UserStore(path)
|
||||
|
||||
salvage = list(tmp_path.glob("users.json.corrupt.*"))
|
||||
assert salvage, "the corrupt file must be moved aside, not deleted"
|
||||
assert not path.exists()
|
||||
|
||||
|
||||
def test_corrupt_session_store_fails_toward_no_access(tmp_path: Path) -> None:
|
||||
"""A corrupt sessions.json degrades to 'everyone logged out' —
|
||||
the fail-safe direction for sessions (no access, not all access).
|
||||
Startup must still succeed; sessions are re-created on next login."""
|
||||
path = tmp_path / "sessions.json"
|
||||
path.write_text("{not json", encoding="utf-8")
|
||||
|
||||
sessions = SessionStore(path) # must not raise
|
||||
token = sessions.generate_token()
|
||||
sessions.create(token, "abcd2345")
|
||||
assert SessionStore(path).get(token) is not None # recovers on rewrite
|
||||
Generated
+834
@@ -0,0 +1,834 @@
|
||||
version = 1
|
||||
revision = 3
|
||||
requires-python = ">=3.12"
|
||||
resolution-markers = [
|
||||
"python_full_version >= '3.15'",
|
||||
"python_full_version == '3.14.*'",
|
||||
"python_full_version < '3.14'",
|
||||
]
|
||||
|
||||
[[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", marker = "python_full_version < '3.15'" },
|
||||
]
|
||||
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" },
|
||||
{ url = "https://files.pythonhosted.org/packages/66/cb/214092c39c4dbcb72cf98b12234ddac2221f8fe2c0acf29c6a70fa83be53/argon2_cffi_bindings-26.1.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:1d98e33bd8bd67d7206c124e200bf2229c4cfa8c9c19f7b44a897f0fc71837eb", size = 25683, upload-time = "2026-08-20T07:33:05.337Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/83/e5/02015b83e9b05ccb85ff2ced424cf6e83a12d3810bc7f66d679a92b69ffb/argon2_cffi_bindings-26.1.0-cp315-cp315t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ccaf0a46cbb380f1fd102a874e32aa629fd3cb0c0e94f4943fa1f6d5edc5dac6", size = 27310, upload-time = "2026-08-20T07:33:06.344Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c3/4a/85e612787d0796878b3b4f6bd53dcd5484b6fe7b64cc6fc7b6e6a04cf835/argon2_cffi_bindings-26.1.0-cp315-cp315t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f0c3103fcff20183e593459cfea6e012281c0e76ae3ed8b5565ad1b92eac3990", size = 26771, upload-time = "2026-08-20T07:33:07.429Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f6/84/ccb003b6f9969820e87656398f4d49c857def71a85ca1588a0e809afd7ce/argon2_cffi_bindings-26.1.0-cp315-cp315t-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c49e853a3bef9dd10329f31f702e7fa9b5c58229ff9c2ff6d069efaf09177c08", size = 27569, upload-time = "2026-08-20T07:33:08.598Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/88/07/c26b76debf0998ee08fbe947ab2058ac5de37d4b9d46b06c17abaa6c4ce9/argon2_cffi_bindings-26.1.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:6376d4b3aca039375ca8bf92f770da0ec424a1ce3a37077a8d3c557411aa56ca", size = 27279, upload-time = "2026-08-20T07:33:09.518Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ee/0d/ead6ddc029f91bc9b9390686dad3c808ab08100d348f6266b5f93f8970ee/argon2_cffi_bindings-26.1.0-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:9bacedc04b0402837586a17f0919e3dfdd95291f441f1f56bd80ec274c2840a1", size = 27774, upload-time = "2026-08-20T07:33:10.728Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7d/47/c108530d9eb86036b78d3af4de28b83b4a2d9a70512bd10ff8e59966aab4/argon2_cffi_bindings-26.1.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:76ae29acace5d33355344612844d588e19deaaba4639d8bb01601e4b1418ef36", size = 26933, upload-time = "2026-08-20T07:33:11.661Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a9/02/0bfc59e781c89acf64c31c388aade9d9d1c1ea38aa1ba1292fe07f607fe9/argon2_cffi_bindings-26.1.0-cp315-cp315t-win32.whl", hash = "sha256:df612391feca41c44d20118f3b88d1b86419465cd1f5496859f715ca60ec2210", size = 24875, upload-time = "2026-08-20T07:33:12.616Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/61/c7/c3e46068cddffccecb8ad94d71135e9bf62bbc789589e7dfadc7c6f59214/argon2_cffi_bindings-26.1.0-cp315-cp315t-win_amd64.whl", hash = "sha256:1a0a29ed86960e44eaace7e081bdfab4f08b012fd96ec8edba71e2ad020939e4", size = 26655, upload-time = "2026-08-20T07:33:13.521Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f4/ca/18b9c8c45fecf34b9100ec6d7946057f14a158f2eaa20ea123a3e82351cb/argon2_cffi_bindings-26.1.0-cp315-cp315t-win_arm64.whl", hash = "sha256:d157ddfab1e8b21f2f1dedda9c09645d98b5ed0b667b0626be600a345d426440", size = 25376, upload-time = "2026-08-20T07:33:14.491Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ast-serialize"
|
||||
version = "0.11.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/54/1e/4f6082cdd6e5a29093513e9a3eabc5ed1c5331a9a84386b2fece80a00a48/ast_serialize-0.11.2.tar.gz", hash = "sha256:976a5bd75845d22f4b52905ddf53ab669ef1b14dba7735f5512841a2ef2b5450", size = 954387, upload-time = "2026-09-13T18:48:55.673Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/a3/2e/beec3364eef4b01793a676d8cd16e9014c42044a5505000ceae3955e33fa/ast_serialize-0.11.2-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:f6a8dfc5ab204a706f6e5d39c6f77c18c27ef084fa2081803a64a9160ce89277", size = 897089, upload-time = "2026-09-13T18:47:22.69Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6f/d7/ef56443df2891c6ba2c4019c2cb3dcaf97c9948da6d963068e04e8dac6ea/ast_serialize-0.11.2-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:cb073bfa15742699d408ac50f60878383b5665ae1791d1b6799ea6f08633cd77", size = 1235218, upload-time = "2026-09-13T18:47:24.541Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/42/8d/cff58d17ba1d0272ff0b7ab5d3bdfcf8f47317eb0f47c001d394bffebf95/ast_serialize-0.11.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1d6ad94edbe93bf1dabc06c9f37d55b898fdabc456aa6d7ced5e23c14f795f32", size = 1216399, upload-time = "2026-09-13T18:47:26.202Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/de/d2/a1da7675af5f42335c36e4da6d86ef4fd7168cead18de81df0a2d6faeb1a/ast_serialize-0.11.2-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:40b2801cf2221bd922d9f69d2f0ebc373c3db47207315d525b2d87fa161a2af4", size = 1282064, upload-time = "2026-09-13T18:47:27.787Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/97/89/5a400a13b2c9c0152ebb5ad45408a3fe5e4e60e325d3ac4e5cf6e915a0cc/ast_serialize-0.11.2-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:fd666cebd6ab3b3c0fd348a6202c26e18a401ee34293c3804d3472266bc146f6", size = 1285864, upload-time = "2026-09-13T18:47:29.667Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/02/b8/80a381c70fd49f0316fb0383c4f9e4c13e81b010b64889bd45898ce8f5f4/ast_serialize-0.11.2-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0d01f61352c96370febf6c0dbd488dee9183a731fb2702170da9163ae317cded", size = 1554755, upload-time = "2026-09-13T18:47:31.257Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/90/97/dcaa34a32d2db789221c125b3eb10feb5089715fe53d9874d627afc26231/ast_serialize-0.11.2-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a0fd40c668b0fa19b8fdb61d9e63d547e2e19cfbfe053a51ef0b6c37070298a8", size = 1301807, upload-time = "2026-09-13T18:47:32.714Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5c/9a/84a22420cb312642d7d31547c644d09a3d101418c6d6b9ef2ec30735cf11/ast_serialize-0.11.2-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:efa819d7c14c8e4153dcd84671826331538be7cbe460383fc6386f5eea5bd234", size = 1301941, upload-time = "2026-09-13T18:47:34.418Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/20/8a/aa5f3dcf1aed9678c25982f40d366004e3c0cac47bc0c240f6b837dcbb1f/ast_serialize-0.11.2-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:a9ffa8a197a721f07a352d0be6185f5b3e6f9aaebfdb66169ed652108531ae3b", size = 1307910, upload-time = "2026-09-13T18:47:36.253Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/78/79/91a5102797fe3dc992171382d8579bcb33cbd1424b864ad3117ac43fb3fe/ast_serialize-0.11.2-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:00119a8fb8c1dc0f1fab023f4d8071fa49e3b0208ee54d589fd463c16ab0124e", size = 1356258, upload-time = "2026-09-13T18:47:37.984Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/51/52/54eeef9918e187ced417c4363eecea66975314cd5b9c91759eef7f7b714b/ast_serialize-0.11.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:0de02520c11391a026e62987a9aa2c3c2ff01545155059ddf0c4bdf2c5ecbe9f", size = 1459057, upload-time = "2026-09-13T18:47:39.891Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e4/cb/fd84b52b15d42f2423319cffd1fb7f1e9df5d5198e69ab0b449c450254cf/ast_serialize-0.11.2-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:b4e4558956b6a0fb35e18fba58f7d1810b1f2c0e6b52352572cd5dfb6b4ef33a", size = 1562447, upload-time = "2026-09-13T18:47:41.727Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/be/92/9fb34f2e64b84a63cca92fb86bd0847b995a63b67477f44c20502fb60352/ast_serialize-0.11.2-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:6061a54f39e82a9f2cbcb9c268fc441890e4818a6636473caa4f4063254e0750", size = 1556423, upload-time = "2026-09-13T18:47:43.357Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/75/0f/c43c44449e7ebc4e83ebd48750088fb06234622faa2d62d2a6dc8970d2a3/ast_serialize-0.11.2-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:85fbb01e83967a126d71f679f2b9528ef0912cb0854aa1a4657314c34e255b57", size = 1687156, upload-time = "2026-09-13T18:47:44.995Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2b/a7/9e520f4a79b639da9ee20c1e747c3d739329e902fc55ac38065f25419f56/ast_serialize-0.11.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:7aaaffc32905159774a107d3cf33dad59bd41b7a0d1bc9885532186753ee7439", size = 1481008, upload-time = "2026-09-13T18:47:46.602Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/48/a8/bdd3989f19de09cffcd8179c131f6741a5a8619705fc75b09541ff61530b/ast_serialize-0.11.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:08eda88a0f290a36c38cab33df8bf7e35eb95bc802ca5beb2c8fcda471a7d10c", size = 1501597, upload-time = "2026-09-13T18:47:48.265Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a5/8a/ca2dce2950875a4ef1d7c298196f803b0adcdb7c15ed0cecc71d84bccd70/ast_serialize-0.11.2-cp314-cp314t-win32.whl", hash = "sha256:76cc294246e60a914326b4ca88c6a5ea89c064906614aaf1537ce82f09e9449f", size = 1119503, upload-time = "2026-09-13T18:47:49.896Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5a/12/3f38e3613d07c46f9f81c5b1352748c6552397cc52825502e2c6ae44c6ea/ast_serialize-0.11.2-cp314-cp314t-win_amd64.whl", hash = "sha256:43b51e6ebe6549bf21416c3c78ee886147b80875a87cc6f69e303dde0d75be0b", size = 1156828, upload-time = "2026-09-13T18:47:51.454Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/22/90/f89a4f67428a261daafdb69a0d0132c27933268702d1ba47e0b61c51aff1/ast_serialize-0.11.2-cp314-cp314t-win_arm64.whl", hash = "sha256:8df32ad4ff7843734a6c2f067ee974f6d3109ee5a2c3e1a9d2f79347bd282a9a", size = 1128298, upload-time = "2026-09-13T18:47:53.008Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0b/55/a1962188abf0e62d84d55892bb044347e434711763b9a1d4ad867a70c1be/ast_serialize-0.11.2-cp315-abi3.abi3t-macosx_10_12_x86_64.whl", hash = "sha256:ab924ba260efd7509492f272d4e236d24564033f20c005d7c63a107c6a76fc85", size = 1235457, upload-time = "2026-09-13T18:47:54.554Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2a/ad/439c2959150718446af76fbe2f4000f35eba9869ef8564f3d9a3d0b1c370/ast_serialize-0.11.2-cp315-abi3.abi3t-macosx_11_0_arm64.whl", hash = "sha256:a586be418eb70a9f1396cea29ddac8f4b9bf277fb73ea2340db31e218bc00f32", size = 1215705, upload-time = "2026-09-13T18:47:56.178Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6d/d8/2c6542fc3e7c56a0a25d8d12d034d5a2d2e1900e292567b1c1dca8e83124/ast_serialize-0.11.2-cp315-abi3.abi3t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8532f20916fa3189d4d785ef2a62d93c4d651ec9c5bffda66d2fc36898351f34", size = 1282530, upload-time = "2026-09-13T18:47:57.619Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fa/ad/6f6755cd0842db46c3b10b1e4735f14aad78d71dea4753eb46933101711b/ast_serialize-0.11.2-cp315-abi3.abi3t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ee732ae167e686d1d3c00f98d7d82b23138304694f0441b14d7ddf9c0f8a921c", size = 1287792, upload-time = "2026-09-13T18:47:59.227Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/03/40/5da672f5dd23fb7dc0c884c97711e56a3540f2fe3c4355a81f8beb385911/ast_serialize-0.11.2-cp315-abi3.abi3t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:75a1c7f46b9c19fc0ae01ca6fd076301628faa2ed7a8edbd55c6353c483946a3", size = 1557971, upload-time = "2026-09-13T18:48:00.96Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/df/c7/2bb25684f697801eb72866fdb94ed5edbff3867ce878b0e542a4a5b9dab9/ast_serialize-0.11.2-cp315-abi3.abi3t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2fdf31a0bb85ea2575cc91669f005e6647d2efed491231c4dc1497bc9a5b3aa6", size = 1303230, upload-time = "2026-09-13T18:48:02.337Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d8/85/754681846f26e0ff1da729b1ffe3171e93c22f0aa6ec3cea5b14e3703846/ast_serialize-0.11.2-cp315-abi3.abi3t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3b78e6fdef3b06c86ed263e1962fee5a7b9d2d158e738b212d13b2c605ee12f5", size = 1302271, upload-time = "2026-09-13T18:48:03.915Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fb/dc/f5521d8cb44b69095c3982ae3658a12c403e0efa19e51aeb9c8a79dff60c/ast_serialize-0.11.2-cp315-abi3.abi3t-manylinux_2_31_riscv64.whl", hash = "sha256:8d62a47714c8bc432b9fabcc29989c815c5da17327d35151f2fd0d85c2a7a5ff", size = 1309529, upload-time = "2026-09-13T18:48:05.562Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/73/0d/649182c7fd7c4f782279bed514de2dd67e48a5afecb605a098d64fdc01fd/ast_serialize-0.11.2-cp315-abi3.abi3t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:8a5ffa70e76191dcf240d3c43e20c93b3bfd26f54d89148c762d57837f5bcd2c", size = 1356869, upload-time = "2026-09-13T18:48:07.534Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bf/cc/aff4d84c16afa742d13a75384127c7d24594dc8c304f0558a15924fd51af/ast_serialize-0.11.2-cp315-abi3.abi3t-musllinux_1_2_aarch64.whl", hash = "sha256:bfbe47a3a7c368f28836e78b2440a3643ac0ec4c67d9fe53588e1448f0a3d35d", size = 1460006, upload-time = "2026-09-13T18:48:09.162Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/94/a7/891cbec2e5e0d7159196159d3ff0646622f3120ff4576c839ac2dd56c719/ast_serialize-0.11.2-cp315-abi3.abi3t-musllinux_1_2_armv7l.whl", hash = "sha256:7f1823275b246f9c7d373be6879e4eec09686948895d4ad083f4b27fd7e4da70", size = 1562935, upload-time = "2026-09-13T18:48:10.978Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/45/c4/2c8c4498340ea9aff87a9fd408309aa25d56dd51d7bbddfdb46a3c31424a/ast_serialize-0.11.2-cp315-abi3.abi3t-musllinux_1_2_i686.whl", hash = "sha256:57c0f5cb0021a5beb1e5e4d6e840ae2f23a28909703ef4d256a144cc1ad3d437", size = 1557109, upload-time = "2026-09-13T18:48:12.616Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0d/8b/c5d4e5226fa18885fe17f949aee3ab1aeb8389c384d946ec1b7c9489cc94/ast_serialize-0.11.2-cp315-abi3.abi3t-musllinux_1_2_ppc64le.whl", hash = "sha256:cd320a5c4f1f2742af97eea22954f776379175c5ef2504801e9a155f2ff9a4d7", size = 1691603, upload-time = "2026-09-13T18:48:14.293Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/73/d6/1d2ca472586f9e3416a289a22f36eeb6dd6f47d77b1a4aba358405babbc7/ast_serialize-0.11.2-cp315-abi3.abi3t-musllinux_1_2_riscv64.whl", hash = "sha256:13b13afe32e845c86a573497729e1b7ddeb26c572c78bf50ece51da23b8fad5e", size = 1483053, upload-time = "2026-09-13T18:48:15.789Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0e/16/d3703a7c1e3c76b144ac9349a54d3926d0749918a8dc13a66cede208b8ec/ast_serialize-0.11.2-cp315-abi3.abi3t-musllinux_1_2_x86_64.whl", hash = "sha256:9d80a81ec84660422579bdb8e789f656a794b48c7a1ae1261f6bd8bc1897d17d", size = 1502499, upload-time = "2026-09-13T18:48:17.405Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2b/e4/d974e55c2e247ef26ed1df01c74940583db9a5b3a8bcaad5732c6e2047fb/ast_serialize-0.11.2-cp315-abi3.abi3t-win32.whl", hash = "sha256:af8c003ce721b0099dd55cef4ba733500fc3054ea0cc8565d8957aaf7cccdeb4", size = 1119739, upload-time = "2026-09-13T18:48:19.005Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0d/00/d229443488e095054d5e0c0cc20689a2633b899d735849ff1b2c8e4f0cbf/ast_serialize-0.11.2-cp315-abi3.abi3t-win_amd64.whl", hash = "sha256:554d117cb916d8032d85007c654d179efbbfd446174c048062778136a922944f", size = 1158602, upload-time = "2026-09-13T18:48:20.524Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/11/75/389fc1a6cfa0c4b2ce522f47d8401329d8bb11732e516d46465960fef1d9/ast_serialize-0.11.2-cp315-abi3.abi3t-win_arm64.whl", hash = "sha256:d60515335750d431e462af6e722bb55720a5e7827192777bddfd9c4376065a4d", size = 1128842, upload-time = "2026-09-13T18:48:22.052Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/45/7d/c4f36898f19c728d091cdfdf960c9488e8d82bbe2e49ba13c05f43907a5d/ast_serialize-0.11.2-cp315-cp315-pyemscripten_2026_5_wasm32.whl", hash = "sha256:89499a439955931281986e97ca4dd3c064bf0d2e0027c0017344eb86667733a1", size = 897204, upload-time = "2026-09-13T18:48:23.695Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b1/54/f67120006fc73a55b6d057d4662d061fbb4eceafce3047c76ca8b382eb11/ast_serialize-0.11.2-cp39-abi3-macosx_10_12_x86_64.whl", hash = "sha256:daadf1c3e0224621607ffe16f1379e4bd372271ed2e1db8a67878f0bab3ef7e4", size = 1240734, upload-time = "2026-09-13T18:48:25.287Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9a/7e/8f2ab68bddbe58a66fbbaad87beeae3e7d7edddb17263d1fc423936cf34d/ast_serialize-0.11.2-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:1844ed9a487fb3de7325c52ddb33f2918b66b65cd54d3f8d83d23785ffe99fa4", size = 1228053, upload-time = "2026-09-13T18:48:26.788Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d2/1b/8a69ab68f4c1603819f0481d756abdd8caf27cec7f1d77caa71007ebe997/ast_serialize-0.11.2-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b17869f4ba261a5fa468a753328a548f4dbaf74b4eadae9e28aff66df7f1425b", size = 1292542, upload-time = "2026-09-13T18:48:28.295Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d1/ce/872f2e00f0467c289e483f0a34543463347243a2d0632748d89fcee5e0dc/ast_serialize-0.11.2-cp39-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:feb16d9c2a720e0120c58dd5d6e7b3c7c86b43249b60a3bc212bcb8fa031e2dd", size = 1294791, upload-time = "2026-09-13T18:48:29.969Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3a/82/36277c12af861c64b375c316135d8feffe3f400568463a8d2b2de4c2c4fb/ast_serialize-0.11.2-cp39-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f3109fe4805384effc8d0f8e41fbf875aa8f389af91b4348c1cfb60ea6e4cb82", size = 1567583, upload-time = "2026-09-13T18:48:31.85Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b0/d7/ec643df91cea8bcbcb4e8011d6a8b08e5119b84f9554879f3e3c786d29d1/ast_serialize-0.11.2-cp39-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:abdb3e49ba053c3486ac1263bee9f16cc9a4a8abd9f8c90bfc21e3669f3ad9d1", size = 1312878, upload-time = "2026-09-13T18:48:33.495Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/04/6f/4c992cd7841ba589fefb14ddc9aff2f6db7f2a615d4074f9ad04115b5ce0/ast_serialize-0.11.2-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a7004ba572f09be34342ccb98dcd4bad5707d3d81adc8cb4c3f685d2a2c51bbc", size = 1312642, upload-time = "2026-09-13T18:48:35.294Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d5/e3/22aaa209c231a83cfea004fd67dee7a7a54da3f169c6c460b14b96887385/ast_serialize-0.11.2-cp39-abi3-manylinux_2_31_riscv64.whl", hash = "sha256:59c25f47524efa052971b860e128b1add0c94ede7dd16b2962952c85c3582365", size = 1319776, upload-time = "2026-09-13T18:48:36.866Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c1/f7/d4685fb54d10108ce44d3bc893ef670854d61645d47ed96d73524db90c23/ast_serialize-0.11.2-cp39-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f3a367e0e05ed2d1b747ceb07aa728a8c204cc008b589127e9bd4f40053d7575", size = 1365324, upload-time = "2026-09-13T18:48:38.412Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/42/3a/250643ffad02bda520c50a9a5f02a5d43259a06f34ce393c91761d134d7e/ast_serialize-0.11.2-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:00bbf1f6669f813b48925b759f7ae4591067d456d443924055cab386e7e0a719", size = 1467653, upload-time = "2026-09-13T18:48:40.348Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c7/da/af66a646b9b7f8fdec95ce83fc7b1fe538b06864bc79bd554ac4fae2e6ea/ast_serialize-0.11.2-cp39-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:ec1c20f89c3e0d83576e3c06f79375ce936266591fe0d5fd969914af3185cbaa", size = 1571914, upload-time = "2026-09-13T18:48:41.968Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/34/82/77a9714564b9e8800087a8afec41527c65c39e49282baae2ac847b9c1c6a/ast_serialize-0.11.2-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:c58bb119b73657fdc5569692f316e1e25ca114bd62f7782eb527c6be438ba3a9", size = 1569862, upload-time = "2026-09-13T18:48:43.701Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/65/06/fa77b52f46b9bd6dcd8ff2b880e3781f8c1a316bb1342bc3de92907c6f96/ast_serialize-0.11.2-cp39-abi3-musllinux_1_2_ppc64le.whl", hash = "sha256:f739e0b601be7300c5697a2573d9200bd1db74b34ab111ef9537b9d5dcd7f106", size = 1699020, upload-time = "2026-09-13T18:48:45.261Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e1/09/239c83153c7e0798e5867d6909cb06f53dccfef02f6999c8e2e21ecb98c3/ast_serialize-0.11.2-cp39-abi3-musllinux_1_2_riscv64.whl", hash = "sha256:cae5addfbb54cc1d47fe947ef9138e9d83849ed1cbc72b819cf36d96a2315b07", size = 1492869, upload-time = "2026-09-13T18:48:46.922Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2f/eb/6108fb9a43fc7ab5529856e38e33c6e3e064fbfe375fdcbb208c7cd5438d/ast_serialize-0.11.2-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:2fa3be25f7f5351b1b39c9f8a52779b2dbf21199efbae564b4746422e8edca4e", size = 1511621, upload-time = "2026-09-13T18:48:48.667Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8a/82/60367e58ef346a41ebc90d3f28593c1b8f5c2cb5314c7b2bbd98910ee131/ast_serialize-0.11.2-cp39-abi3-win32.whl", hash = "sha256:d70556a2f9230a44c99a655774cde823f056efc34466eabfb4085f0cb1ea9f99", size = 1125873, upload-time = "2026-09-13T18:48:50.661Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/23/bf/b419c3205ce1143ba7c69baef4f0ba43c14d8712113bf34f9e0d27d609be/ast_serialize-0.11.2-cp39-abi3-win_amd64.whl", hash = "sha256:b9065dd23131a23b41f5bab3bf4e9b3c350a3fe8e36e8200eded9b729fcea484", size = 1165434, upload-time = "2026-09-13T18:48:52.169Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/91/a7/c8bbb2173f7a7131b3b2412035b2d814ab5ef2ce9799bd06f07c451640e4/ast_serialize-0.11.2-cp39-abi3-win_arm64.whl", hash = "sha256:dab599cbdcb7b45b18c41fad746645580b3a24357082b7f0e8921cd373804f27", size = 1136031, upload-time = "2026-09-13T18:48:54.04Z" },
|
||||
]
|
||||
|
||||
[[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" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c1/8f/9ebe220eab48a093d1a5a5e339ab0dc7316eef3bb04d63c42f0251b61f50/cffi-2.1.1-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:dddad92b554513a31f272570678ba307fb9f618f05e3d4a5eacafff9eae03e1d", size = 194043, upload-time = "2026-08-03T21:20:48.179Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ff/69/844bad3ece306c4782c2ecb93597035b6690d48704b803914c199da1e8b3/cffi-2.1.1-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:da0e573f9f97159390c89d9f1a9e41908b66d408cc5b58d08cf3847d844c531b", size = 196737, upload-time = "2026-08-03T21:20:49.457Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1b/8a/af668013284634733f02d683458a0728739c7d6ddb5e14cb0c20832266fe/cffi-2.1.1-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:fb92203a88b3d3053034db775110081c49d28be6551923805e039924093761e4", size = 184933, upload-time = "2026-08-03T21:20:50.639Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0c/75/2f5207ff6d1a613133b23a5203cc0c2a628313b5eb3974d7956ae3c57950/cffi-2.1.1-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:2ae64be792b8966f2c69538199728b290e34726562896df1e5dc8ffd8d8188e8", size = 185002, upload-time = "2026-08-03T21:20:52.173Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e2/31/9e1313b0a6e30e91b3b3d3fff51ae99c857c07738e3afcce1f7334e1b7ab/cffi-2.1.1-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:507a24c282e0f42f8ed737cf048572cbf580468da5555764a8331735e9c736b6", size = 222271, upload-time = "2026-08-03T21:20:53.462Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/50/e3/f6234a833e6e08c7007003074723c406559eecf9b48dfc97471e5a8eb7a0/cffi-2.1.1-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:246fa40ce8645a614ff682e0b70f37134e460eaf93a775e0cbe3cca585a67a80", size = 209919, upload-time = "2026-08-03T21:20:54.783Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0d/fc/5f74e293fced6edb51af3a46c4ccf6c23c9943774ecb375ddbd522c76add/cffi-2.1.1-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:471cee653ae88de62096552e6d24ccb4a5adb8c8c9f10b5054d0122c15bf2779", size = 208529, upload-time = "2026-08-03T21:20:56.066Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/44/16/29e6d01b388bef055ecd6ca8244b3f4d336bd09e92d5d892187b9601084e/cffi-2.1.1-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:aeae0e330c9f6acd681f647d46cefd30c29f93e3392882e792e82080c9691399", size = 221630, upload-time = "2026-08-03T21:20:57.336Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a4/18/fa7f1f6857d5eb88a4ca99ffcbfb7c387a287ccc154c64a73e86314745d7/cffi-2.1.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:42a494cee34437f05546455144f2b5d9ac09b1face62bcfce597d2e521066688", size = 225134, upload-time = "2026-08-03T21:20:58.675Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e0/9f/e8e3dfa04a1b4c241f8c91faacad872b4d4efd051d49764ad4e2fd4b9fea/cffi-2.1.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:cc572dace3f60ef98d7b12ff411d20f5362feb31a0439eab0085bbfd349982d7", size = 223197, upload-time = "2026-08-03T21:20:59.968Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f8/7e/8debeb04f1ab9fe2a6963964cd6f1aaf7192627b83926586a6a4e089c9fa/cffi-2.1.1-cp315-cp315-win32.whl", hash = "sha256:4f42141fc14250de6dde5ee7ea4432be017252d91f19c5ad043c084cea629cac", size = 177683, upload-time = "2026-08-03T21:21:14.901Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e0/31/5158704cc474ab65c1647932e88be78dc0873f47130e253be38bcaf13d01/cffi-2.1.1-cp315-cp315-win_amd64.whl", hash = "sha256:e6e8cff14d6fb0be70a09c0bdc58096f501952d04624ebf867e0e56da2df8960", size = 187897, upload-time = "2026-08-03T21:21:16.108Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cc/4b/b3a2da8570c704ffc0f9762cdc3ec0f02c8573798e0b5cf7f11c82bbb70f/cffi-2.1.1-cp315-cp315-win_arm64.whl", hash = "sha256:27350daa11d4f10c540e6e89dada4c54feb7256ad03e9a4dc075ebad7ba360d1", size = 182935, upload-time = "2026-08-03T21:21:17.271Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d0/ef/5443574510a1207e6f6bc38ba6e1f1de36cb48fef07b2728bb896a21f430/cffi-2.1.1-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:c26608d2222fb1e94487e4a387d85f13eb55d5ed725cb25a0c589ac4ee60e7bc", size = 188464, upload-time = "2026-08-03T21:21:01.163Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7e/ae/a56fa8c4686ad50e148fcbc8d3ae0d03915ff5c30d795058988c24118cef/cffi-2.1.1-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:4be96343e422f2dfcd12ab5c9f5aebe03f82f737c6bffeca6830b3875cb44aab", size = 188262, upload-time = "2026-08-03T21:21:02.382Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/53/b2/6187f46f2912276a3ae284076109cc5c8680482f11f766ccf26db4a86427/cffi-2.1.1-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:937c0052c05a31ca1daf18de3158eed4dbfcb9cc107adbea227728d647be701e", size = 223779, upload-time = "2026-08-03T21:21:03.553Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8a/f6/c3ad28bd19f77047a03084424fbd4cbe997303267c14423737324be0385d/cffi-2.1.1-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:df423d40ee8654634421812bc3b196da3f9bd7d32929da813f8394c4348a5358", size = 211520, upload-time = "2026-08-03T21:21:04.863Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a0/cd/ccac9013a5bd9fd764de118674ab9c805b5ca10c19270d90ee273f8b2240/cffi-2.1.1-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:a730a083190634c65cca36ba5f489531576ebd79bcd5c8e172130f6453127231", size = 210673, upload-time = "2026-08-03T21:21:06.223Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/52/86/2976131c639aead931c5bee5aba67e4b09fbeb8018b6f282f70803f923a7/cffi-2.1.1-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:363e05fa78e15116c3c32c210ee36884fd6b9afa6d440e47112c3bd511d64cb6", size = 223835, upload-time = "2026-08-03T21:21:07.539Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ac/0c/33a7aeab2f9c76918c52e084beb39c570db3588133412929e8ec06fab90b/cffi-2.1.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:770de9db11e84213beec501cfcaa013b019820ca881e03344dea5844f7876d94", size = 226705, upload-time = "2026-08-03T21:21:08.774Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e3/26/2cde30fdde421130bfc18f70395731a6e6b2053c6a1978a5258ff04e72fa/cffi-2.1.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:7da0c5eff80f0197f3b3d1232ec5a682a9325f4ae9016a78f5f5ca35f9ced1f5", size = 225539, upload-time = "2026-08-03T21:21:09.911Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6d/cd/a361394c94b2129d604bb846f624a8e88255a3ee33129c434a00d715e64f/cffi-2.1.1-cp315-cp315t-win32.whl", hash = "sha256:06c72bb76605a4b0cd0aad6930b69d4baf7dd5d806cfc409b824191099700e66", size = 182707, upload-time = "2026-08-03T21:21:11.226Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9b/b5/ba2b299993c26577d529b6ae29841f9e15b9fcf004d65f423f4fcf94ade9/cffi-2.1.1-cp315-cp315t-win_amd64.whl", hash = "sha256:d9c275eaacd24aa73f94ffd6de08fc3f932424d8b6c376f4bed7cde376fe7bc3", size = 193772, upload-time = "2026-08-03T21:21:12.39Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/aa/29/35e016098c814cd93de9cd320c66b5bfba14dc6ecedd3cb518fa7c408c69/cffi-2.1.1-cp315-cp315t-win_arm64.whl", hash = "sha256:d18e5ac0f2f03f4f518d3e23db0f0cad7faa1da8620e9c09461d443bbf6e6692", size = 186360, upload-time = "2026-08-03T21:21:13.636Z" },
|
||||
]
|
||||
|
||||
[[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 = "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.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "cffi", marker = "platform_python_implementation != 'PyPy'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/bb/ad/5d6702db60b1e40b41ef513b6967ff5848f307d50f8449baf1634f5908f1/cryptography-50.0.1.tar.gz", hash = "sha256:5dd9bda1c12b4162f6ff568eeb5e0ff956c28d14406e875cfe8a63a2d414ff20", size = 880381, upload-time = "2026-08-25T19:45:45.499Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/ba/19/797e2aaac9df6a66f1550f49979dc1b1e39ecd2077501c30efa81e8d5d67/cryptography-50.0.1-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:b8f852c65863251b9e3a1b8c150ce21e59b522dbb6a7d4bc80e680d38388e986", size = 4010153, upload-time = "2026-08-25T19:44:03.155Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/90/34/9ce9a62ed9dc82ca9fd6a34445b6904af56e5f38b3eae2ed32e49c36053d/cryptography-50.0.1-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:53e279950892dc102c6b4e52af03ae5ea92fac572a1ddab78ca73a997f62b69f", size = 4723133, upload-time = "2026-08-25T19:44:05.461Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/57/26/e6d4fc8512a51a5f9ee7bfdbfb853bce1197087df40c9ad993ad370b846f/cryptography-50.0.1-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ff838d62ec1bfce4f9ba7fa16f4a7b554cd8d0c299e6be37502161a660c84eef", size = 4712478, upload-time = "2026-08-25T19:44:07.375Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e6/de/d3cdc2815697aae84126cbd6a030ca7b6b452e28a88b501b836bd3aa7a86/cryptography-50.0.1-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:e74591e283fe6eb956416c929eb58262a719fe0311fd9054c62c3350ed8760d8", size = 4730726, upload-time = "2026-08-25T19:44:09.294Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/55/32/38c0d344b98c06d34b5df8946565a9c0d6dbf32c8e0730a7f05f0a3c6cab/cryptography-50.0.1-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:5fe002589592ed749ce77fe0695fcbd3500dd61d7d6db5858a7544c612fa8e45", size = 5353524, upload-time = "2026-08-25T19:44:11.96Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e1/1b/82f0f0d8858d4432be1af790477edf62aef90324041aa07c57e57bef1af7/cryptography-50.0.1-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:51593d180cf6d179bde5c5d065bed81386b1f381656ae7d042b7ffc87a9895ad", size = 4746720, upload-time = "2026-08-25T19:44:14.051Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/29/ba/042ca458b8c64348c768284b5d23e69b92ed53d057ab779fee628564676d/cryptography-50.0.1-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:359e62deae718bce96170e223fdcb6357e4fbd3bb7a3a75f4430763532560e49", size = 4361866, upload-time = "2026-08-25T19:44:16.167Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/39/3b/e96c1ef71edef71057c7e3c3d982ce8fda554e0c52d0cc19c18845cde3eb/cryptography-50.0.1-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:e2ca8fd1b6b4b82a1c4cb02841d0837e3c12336c2e24b520ab8ab3b969733d8f", size = 4730028, upload-time = "2026-08-25T19:44:18.085Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e3/38/45abd72ef63f2e7d0754a6cacf97bd8b69512ace7f6130d24c39ece65da2/cryptography-50.0.1-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:76de83fbd91ac49c0feaaa983d0748fd7a53176afac5fb3bf7478d244f0eb527", size = 5308405, upload-time = "2026-08-25T19:44:20.197Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/85/66/6ccca4722987ddedaa7fc9c3f4708af7431f5535666c174350830888c6b7/cryptography-50.0.1-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:51afcfceb15597cf2635068e4ac9a56b2abde622edde17f37d85fd7b5306497a", size = 4746230, upload-time = "2026-08-25T19:44:22.376Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/13/0e/b1f92e013228111413f2e6743948b80bc24dfd3c1b87ba98ceea16f5df89/cryptography-50.0.1-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:be224a65493ec5b74a158ff22a5522ce4a5ca1e543c647a3a4730d4a09e5f959", size = 4862596, upload-time = "2026-08-25T19:44:24.472Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7e/22/c3654cccc856e9d682817b04ac3ee79731cb09ca6f95996a95c904de2883/cryptography-50.0.1-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:9ebcdd5519be9b652a46f507817a74591774fc3d6923ac364e4dfa64e36b291b", size = 5014082, upload-time = "2026-08-25T19:44:26.709Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/42/8b/cb12b1b60c91b074ca6bf0fdd59aa8f10d8bc5f73af8faece86ef0421b37/cryptography-50.0.1-cp311-abi3-win_amd64.whl", hash = "sha256:aed8db4f6d71c51efb89530e12d9464e7bf2923d46c3205dc794a2a93f8c0648", size = 3842826, upload-time = "2026-08-25T19:44:28.784Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5b/f0/424cb557d99aa86ac55da5e2add02e2882e44047b6264f93ade1b975a993/cryptography-50.0.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:30a125032e5642a21ff816e021152bd4e7e94f03eff3f4b7fca41cd22bc3110f", size = 3973525, upload-time = "2026-08-25T19:44:30.7Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4d/72/3a2711d967977ab5fc80b782837c7e8d1ac7445e764c20c381a265c57ef3/cryptography-50.0.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:a0b1a59e3a089064a0ec309e9428c8e3ae4e161419d20ac33600767e83fc658a", size = 4708817, upload-time = "2026-08-25T19:44:32.773Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b4/f2/bb1f56e10815b789df0b409a69fa4992ff3d3fef9c72747f4a6b26fed38e/cryptography-50.0.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8921d58f426793c5f1b47f0b59575780de9a095214958d0eb37d909593db8367", size = 4697300, upload-time = "2026-08-25T19:44:35.144Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/08/bd/ed5396be499ffcf8807a585bfe38b71a1fbdd1c342b4f9b6d0ef5162a946/cryptography-50.0.1-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:a8f40ea47330e71b594a7e246898f93177c259490c63183dbaf9e571d71ed9a5", size = 4716039, upload-time = "2026-08-25T19:44:37.192Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f6/6e/1cf405c5c8e8df7545378048e954792f00b7f2367af8863ce8b8f3e10607/cryptography-50.0.1-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:a255449073358275b64b67d3f595f268bbef70e72b6edb65e0c70c735bf739c9", size = 5332388, upload-time = "2026-08-25T19:44:39.16Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/47/92/b4317e8c32c4f47b062f5398bd79106b220a124546f42be83bf32b761e2a/cryptography-50.0.1-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:8df2de9102026855887e4587084f6eabd80ed0f345b8ad8a7ac27ab9bf4723e0", size = 4730293, upload-time = "2026-08-25T19:44:41.298Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/39/0d/a1e7633e2c744d0f2983320a27e924ef2264c79c56e1a58d5fb0a1cfd413/cryptography-50.0.1-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:ac02b07824d4d1001bd4367599f839c19cb171924c796e52c23508ac14c2c0cc", size = 4346031, upload-time = "2026-08-25T19:44:43.245Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/88/dd/b215616f9bab3fc18510c78a4e5c9f362d77838503c363dc747c7d4f5c6f/cryptography-50.0.1-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:cbf74a81765ee67413503ca6e26dcc4f6f5a519822436cc0a1b97aab6c1b8a17", size = 4715344, upload-time = "2026-08-25T19:44:45.291Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b1/1b/ec3ebd31741d0e963612c4fe43caa39341b9b1e031e469820e42e4c83918/cryptography-50.0.1-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:16c5ecd954b3330ebfb6605eca4fd952da8bef376551d5cc264534e3770a9ee6", size = 5287201, upload-time = "2026-08-25T19:44:47.297Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1a/01/0127d11a762b31a9ee0221894f540318761783f3fdc4bc5d057698caebd5/cryptography-50.0.1-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:79bf008d1f9af6071c797ad133e39915dfee7614f18f18f4db9072eb715064a3", size = 4730023, upload-time = "2026-08-25T19:44:49.435Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9e/b9/e7425ebfb599241a0c1d7000f1b466c3062da66c19d9525031315dff7213/cryptography-50.0.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:330fbb252391c596f1ae42c5754449dc924e6ad012dca8efe0d703f9f2d12ec6", size = 4847362, upload-time = "2026-08-25T19:44:51.94Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2d/fd/60d0ddf4defa12e482c9d5e0f554384d6e8ab25341fd15f060028fd92e6a/cryptography-50.0.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:42be3bb70596b3abe4ac097b75be223e8b3ab614a0e5de068e3dcc54d71d6149", size = 4999247, upload-time = "2026-08-25T19:44:53.876Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4d/56/bc4f2b209e766c93372cfcd59b781a0b2b59700f62a969580415b699c2b2/cryptography-50.0.1-cp314-cp314t-win_amd64.whl", hash = "sha256:f74455bb086a85d5e81246412602aaa97ed095e504cd40dd261ef50be42205bf", size = 3825806, upload-time = "2026-08-25T19:44:56.209Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/84/a9/ee16a903f13755e914d1eecc482fe64d1f10761c3960e5d8fa6837377aff/cryptography-50.0.1-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:ca83d00d9e69cd5eb63f2e69c3a5a59e0cecae5ae14c6ae0b35830fe3b37bad0", size = 4035307, upload-time = "2026-08-25T19:44:58.305Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5e/a5/9ec7e81e8526c0d7a387d73386b2daed3f39e10d81a85930bd1b6bfba65c/cryptography-50.0.1-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:05ba322c4da95b262a212c345af888ef2c37c88c0509756ea00a0e6d68850f23", size = 4751900, upload-time = "2026-08-25T19:45:00.401Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7e/3c/0e77bd5ffcf078e9dd27d3074aad6c030d9b10d0bf69329d573c927a188c/cryptography-50.0.1-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:e22dfed744bd4002e909464cb23d2f0b05c6f3113a79ef2e9864a53db737c733", size = 4738357, upload-time = "2026-08-25T19:45:02.786Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/27/3a/3c5f80daa4dcd47323c7af8a2fcb90de27a33564d4fcac69846c0972691a/cryptography-50.0.1-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:4c4188f7c0cf655be5c06342b817ed0f9595b69ffa2b12026e5353eed29dea88", size = 4758474, upload-time = "2026-08-25T19:45:04.889Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6e/2b/214cf0cf93db9628c3c20c896b229f327f6fb1b20e4b3743d8ad3f00af8b/cryptography-50.0.1-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:2ebbfb0f1fed745e91796e3e1080a1440423fdae8ece1b995a1d80883a409054", size = 5375862, upload-time = "2026-08-25T19:45:07.163Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d6/51/3f9701867a46b6c1740c9b52fc4d3bed6cbdcfedcc9b6e64305c07f39cff/cryptography-50.0.1-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:407fe2b6db00939c05c0e945e9914238f2f0a430974839429dafc82b1ee6bee5", size = 4772942, upload-time = "2026-08-25T19:45:09.396Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0d/5c/13ea642e08e2544d0f5396122055f4820cfacb3203562197b5967125ea97/cryptography-50.0.1-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:2b34d76a652ea2b6faf777c35df230c5637842cd904e04f16230c3f9f03e4361", size = 4383347, upload-time = "2026-08-25T19:45:11.659Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/84/d5/7d1fe1cb93f91c428093ff234e128c89ba8ea61a6f26aab406081f9b996e/cryptography-50.0.1-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:01f41478cf33fc605a6a089cd56d28b45c6c0b45a1928b61797f2621a04bac71", size = 4758050, upload-time = "2026-08-25T19:45:13.745Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/dd/04/557fc5ead96a829e0bc812a3b9dc4a52a2f27e4f7f5950da7ff27653a805/cryptography-50.0.1-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:fc3ed7ebd2a8c96f5b166de0ab9b624996bef3b07bbeb19364dfb78222c22c80", size = 5332955, upload-time = "2026-08-25T19:45:16.193Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8c/eb/5d7124083e8d8cda8f5b348f544b71ad6f707ad63193758ef4d8e569da02/cryptography-50.0.1-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:9dde0a357190eb3b1da1bb9ab750e9c85cba82ca5977aa0836cbb94e92611239", size = 4772694, upload-time = "2026-08-25T19:45:18.315Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/63/8e/f1f955e0921dd2b6d22eae7e8d24a4c4b638d10735ffbf6a71f99eb0fcb8/cryptography-50.0.1-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:fd3718b960d0b5dd213cdf03f3bcb7000e69dda0de8b956061947ff6bcff5558", size = 4888413, upload-time = "2026-08-25T19:45:20.4Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1f/ab/89e2b798d2c3925f82e2bb72d5979f3d2f6da2dd22ef4a8cd8b70d920039/cryptography-50.0.1-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:2a93d05e34d5f67fba6f891fe85d929999baa7195e853923ea6d7576c9e68c5e", size = 5044355, upload-time = "2026-08-25T19:45:22.353Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/99/89/87ef49ffe383ef4e147d27b7bf2088fb0b54ea409dd87b5a89442e5828a5/cryptography-50.0.1-cp39-abi3-win_amd64.whl", hash = "sha256:55d16b1ef3ee0958d893a977b19777887e546c9954ea81b200c3301a864013f2", size = 3875429, upload-time = "2026-08-25T19:45:24.418Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fastapi"
|
||||
version = "0.115.14"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "pydantic" },
|
||||
{ name = "starlette" },
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/ca/53/8c38a874844a8b0fa10dd8adf3836ac154082cf88d3f22b544e9ceea0a15/fastapi-0.115.14.tar.gz", hash = "sha256:b1de15cdc1c499a4da47914db35d0e4ef8f1ce62b624e94e0e5824421df99739", size = 296263, upload-time = "2025-06-26T15:29:08.21Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/53/50/b1222562c6d270fea83e9c9075b8e8600b8479150a18e4516a6138b980d1/fastapi-0.115.14-py3-none-any.whl", hash = "sha256:6c0c8bf9420bd58f565e585036d971872472b4f7d3f6c73b698e10cffdefb3ca", size = 95514, upload-time = "2025-06-26T15:29:06.49Z" },
|
||||
]
|
||||
|
||||
[[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 = "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 = "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 = "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.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "librt"
|
||||
version = "0.15.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/36/9b/356320fbae2ac8467e21c5e73e1389c80468e4998c62cc7d3536cc51b614/librt-0.15.0.tar.gz", hash = "sha256:4e66cbe84437497d951b799d3e1551291b6fb3d643820a7014b3655d57a59162", size = 214338, upload-time = "2026-08-07T10:49:42.663Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/ba/39/99c25030e782bdfb7a21be8c05254806a2e4bbb05c8d50c2a2130acbfa05/librt-0.15.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:e87bc679f86a99aa3b26e3c78eeb821a247c9a28eae48eaafcc32c3bf4c3bb9e", size = 151021, upload-time = "2026-08-07T10:47:00.057Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/14/43/f4b1bd1b2888798a1409808889a25ea1ba49eaabce7d681ed27734c2df9d/librt-0.15.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:71599e011ac880e8e45d46047d714871894c7d4ab6f25626f8d4f89da21f368d", size = 155267, upload-time = "2026-08-07T10:47:01.311Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0c/db/3ad9c965c72f1e1d6beeec44ec10a54e17be8ae042fbb4baade16cbadced/librt-0.15.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c802434092b769b1d613ed2e13fac15fbfce1934a74bd10283b03c0fae231cd1", size = 503136, upload-time = "2026-08-07T10:47:02.45Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4b/07/5888a6d76acd62ebce66c61b74d94e9370b9c32929f111e487bb6546f8ed/librt-0.15.0-cp312-cp312-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:5500eeae393a184d14e1f35645962c27129d20c81afa4069e6ef826ebc2b3aaa", size = 496670, upload-time = "2026-08-07T10:47:03.675Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/29/39/ab57cc2f5b276156da02bb7f5a8921bada1cb1993ffec99acf811c602c23/librt-0.15.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6ecfc32dfb46fb7b565bcd6abf9412acf978775a998273d22888a6d7953730dd", size = 513688, upload-time = "2026-08-07T10:47:04.981Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a7/b9/bdbb0b648b5c2befb031f4c6f3b1dd857415e8fb492a25a3c764a6681e6c/librt-0.15.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:89cc46cfd15022e35084355478c9ac809d90b1152222706ac9a7655ec21df6fa", size = 531904, upload-time = "2026-08-07T10:47:06.211Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/93/26/473c2e4b6c104e9e58e27ce95fc8005c8bd4fc36cae4f254371125a92db8/librt-0.15.0-cp312-cp312-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d5f51401d102c885b9ca509e62c79b1dbff286e1b9b047fde6f763780789356d", size = 524427, upload-time = "2026-08-07T10:47:07.592Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/26/60/03b3abb82b41714671b907bf6989b228e31e6a8af52dec82b5b0728dc250/librt-0.15.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:cc30523e3f1a23fb7511cc659834a0d01a1042bb9de359bc1c131cc4ec6c9656", size = 543155, upload-time = "2026-08-07T10:47:08.866Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f2/0e/9bb1f0a4affbd0a1888f4f79dc03ed2a299d9a2c26c59ab2a97dcbf11903/librt-0.15.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:59fe030d8ae4a57e3fb7756bf35a858de74e04066fc8555c53d0af979132af81", size = 546890, upload-time = "2026-08-07T10:47:10.327Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/dc/84/6937a280d461f7de6e031ffb02edc2b7c3c90d49d630565ce8ff27cbc5f2/librt-0.15.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:5a6526a2a956bbb1e4ae3568c82e650fc99119c66bb011ea60715744955a2b4d", size = 555163, upload-time = "2026-08-07T10:47:11.798Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bc/95/2a2853c1ee014bf102116e7f897a04beeaeb2461b45b79af98bdfb95f1ef/librt-0.15.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:85ea21ec6730194d67156b0e0b5430ccb1d61f8b8b907e39b37f9812b74a13f0", size = 535812, upload-time = "2026-08-07T10:47:13.279Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c9/4c/cf9601c1b4c5f09280acd5d83abdb2e68527a2be8257136eb42304218622/librt-0.15.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:1e47b8ba865d7ede071a91a7163073bbaeb72541f1ef8a07d512c45c7b5007f2", size = 573688, upload-time = "2026-08-07T10:47:14.727Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/47/6d/9ac7cbec46189a7625af4b5acbd25f10d827f4141b2002181848c8418923/librt-0.15.0-cp312-cp312-win32.whl", hash = "sha256:a5207ec414d1c4a2a7231b2086970dc036f94293cdf338190984958a013a42f1", size = 106138, upload-time = "2026-08-07T10:47:15.973Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/38/d0/2ae99c83be86ce23f925ac1aeeedc777e97f427c4a8d190c70d0a16e9a87/librt-0.15.0-cp312-cp312-win_amd64.whl", hash = "sha256:73b30cfa976659b3917c8f6153bdb0591c6a9ec6583599fd24a689b690622022", size = 126974, upload-time = "2026-08-07T10:47:17.049Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5d/ef/dd24f9635c730b86b87587967dda7516b1845e8b17684603d31607fed598/librt-0.15.0-cp312-cp312-win_arm64.whl", hash = "sha256:a54cf9e0ef47b96af580849db5471142200568ce1e02cbf416addab551369570", size = 112292, upload-time = "2026-08-07T10:47:18.222Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e7/42/467b53a601b406ccd7b97c1fd54b59cb34f9185ad5ce7e9d5c3c4e8961c8/librt-0.15.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:db13ca398005abcbe538deda87b686d9bd08b7001cf40c4c06b444960ae10a26", size = 151029, upload-time = "2026-08-07T10:47:19.312Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3e/e6/36c2299b7a94b84fdd01220d8a777a71be5be0925bb0dbdf71c0a06a34d9/librt-0.15.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:aa1f1995789dca3698bc550aaceb09a51bd5df0a057ff84ff15296cd1975b801", size = 155194, upload-time = "2026-08-07T10:47:20.398Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c9/b6/ed5071f9325845e670bd36012757419767fbf56af77ed483077b9e4db541/librt-0.15.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:55456ea87d8df21808446d03817be2f65e20391c1c615d9187440dff28cd08dc", size = 502568, upload-time = "2026-08-07T10:47:21.652Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7f/81/6450c67c3615d87704bcbc21323fafc69c799b06a044c447529f725d4b01/librt-0.15.0-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:5a86a5a08c2235316bdb359d5dbb6ce0abfca7fac06363103e2c5af571d92f95", size = 496153, upload-time = "2026-08-07T10:47:22.925Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e1/d6/5f52b722bc75076954b3bfd49be15ea362df4d580c6fb315d0f617100d30/librt-0.15.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e56b6a368529bed262da40ce13f8fef590db0479819cca84f16a1f01ac356d0b", size = 513336, upload-time = "2026-08-07T10:47:24.213Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8d/e2/c08fd1d36ce63ea5a12b85c5d37f4550b5f86a692167e41e5a74222607ae/librt-0.15.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:234d8d394721fa0d786af15ebf1f3fb7f3ed82fd1cd0cde45c2f247b5d4281d2", size = 531661, upload-time = "2026-08-07T10:47:25.507Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3f/d8/d9482fcbeb177b9eb87bb3899eeb3b42be690313c652f9e146b1d0681fb2/librt-0.15.0-cp313-cp313-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d8363d7accb0286ac3a0e633f396e93800dafb8150494505daf9515bbda591f3", size = 524487, upload-time = "2026-08-07T10:47:26.79Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/10/cc/075171517b41f861753034fbb151b42cfc83bcc853849f24f5e66fd60ccf/librt-0.15.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:0f0ee3644d951f31055ad07d77d92520e84505dd7a432cc4cd501dd70ee06785", size = 543201, upload-time = "2026-08-07T10:47:27.999Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b0/03/42c2330f37eeb475b6affeedd06518f60035f323af3a839335e3fc9fef2d/librt-0.15.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:2cfd1a81a648806e6a7717be4cc4d1bb392fa229752bf8444ba365e381e984d6", size = 546467, upload-time = "2026-08-07T10:47:29.396Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/57/1e/1ad4c5638f7e64d8560328bd25c54b409a661bdb6ff254b38ff90744288d/librt-0.15.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:a6cd22c9da0d866558e46a041f1cc0c2bbb26b61b137b2347fa834c332e1d101", size = 555139, upload-time = "2026-08-07T10:47:30.815Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/49/41/39fa7d15db1204cd1cbe6514680fbdc243adf754a0885061308f43afc013/librt-0.15.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:6d5225ef8801e4ea5e482fa9b5dfb891dd9ef6f6d870f1f25d449ca2c70ac218", size = 536050, upload-time = "2026-08-07T10:47:32.222Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1e/88/c6dcf0dd8e26dc0c9a499a2abab8646c86dcaf9ecea9524cb46d3686331a/librt-0.15.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d28a05796b99f749bf8794f17ba9ba1612d0076b802e9cfc62c554634e9ce3b", size = 573700, upload-time = "2026-08-07T10:47:33.527Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1b/9b/ab54c71a7918a7c34fa5327fb61390a77446a07a146fbfb1165250a61035/librt-0.15.0-cp313-cp313-pyemscripten_2025_0_wasm32.whl", hash = "sha256:2067ff438048cead9d223ca5675bae2a25e520a7c3e6c1498bf9c6892d22caab", size = 82194, upload-time = "2026-08-07T10:47:34.835Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8d/b2/4f9a243bb892395f3becb80789ade13771701091f9f07ab8230247953ba8/librt-0.15.0-cp313-cp313-win32.whl", hash = "sha256:1cd3b721f24c206398b9e26da3c3a9c011e6e89d06f318ba8ebefc30f1003890", size = 106231, upload-time = "2026-08-07T10:47:36.251Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bf/af/64aff4885a40b93132382f2c314647d722574605416504379184ef3045ea/librt-0.15.0-cp313-cp313-win_amd64.whl", hash = "sha256:f395a4a9a03ac062dbe9a9f82e0c720502e590a38feee6a757bc82e9c63afbd8", size = 126996, upload-time = "2026-08-07T10:47:37.453Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/27/83/335bccf6c7cb9028cb0b54aead27d9ece3f01f83bc6baa2abace5da655c1/librt-0.15.0-cp313-cp313-win_arm64.whl", hash = "sha256:0a15cb554761247d84a3ec0cbdf4078d70725384f0e4662c0fa3b26266eb60ad", size = 112188, upload-time = "2026-08-07T10:47:38.729Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a8/93/949053fb462eecc4a9a5ee770a81f4b40be7b79538b245545d4aebc6b58b/librt-0.15.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:f5de7feedc56337a088eb15cd9fafa9938367362221d8cc62c642b7f94821993", size = 149833, upload-time = "2026-08-07T10:47:39.86Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/61/ca/8281aa6cd560a3420e4497729f6b704b53be3eeaaef82d5aeadddaf7441f/librt-0.15.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:6c0eb900c0e91f4aebe680845242e614f1864edfd44106380d0752ac29522bf8", size = 154088, upload-time = "2026-08-07T10:47:41.065Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/dd/02/1a1662dceaba6a086360891448d5ce9a7d3555976cae59a31a39d744b9c7/librt-0.15.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e8c9a650a188e38bac005048cbe6342e81407782944d01934540ab75e417df21", size = 494215, upload-time = "2026-08-07T10:47:42.388Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/69/84/99211619dc656370a3740c33d2b0b6d5a3fb1e73689314f6ed477a397dc4/librt-0.15.0-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:92bfed8deec93df30286b9fe9e3b1dd17329cc076a192b4ee5ec223841d54953", size = 491173, upload-time = "2026-08-07T10:47:43.683Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d4/aa/5448d0b05f4579b635d3899176817ebf561af0e57bacd425b5b1887264c1/librt-0.15.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ec4b19788f835711a2072f9dbe6b03b3bf32ed1f0fb30cf399bdd59d9f0c33fa", size = 505512, upload-time = "2026-08-07T10:47:45.314Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/95/82/01940e40b83c43a546c4a3c896cf34ca272a9690899d55914e4827b3dcce/librt-0.15.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d4c7bacb70930f3d0a56f4ecf1be474a1f0d941b01dd73b756f3c256d42cb879", size = 523073, upload-time = "2026-08-07T10:47:46.66Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/88/fa/759c0030f3ee371439eb26de34fc745807caf0abb878af7af4b8b7c3dd3d/librt-0.15.0-cp314-cp314-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3e79f05e4a08b4d880342673312bbc895b56df7765605796f15902eb5367d3ae", size = 515080, upload-time = "2026-08-07T10:47:48.319Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0b/27/894e072228fcb159703c655da69f8cd10dbed489c36e3df7dd032a2483be/librt-0.15.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a417149c0cba4d50b61e992e5a15e69eaf96746609b461cc4ed168aeef6b79dd", size = 534164, upload-time = "2026-08-07T10:47:49.875Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/98/a3/0078e91c1f36f8815db17827de15650b9a3fe56c55fbf998c854b34e40d3/librt-0.15.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:da7a94d6a3411f579d72aa3e3bc5fbca7ed4549f3dbd7e5de3aa567333374285", size = 540616, upload-time = "2026-08-07T10:47:51.408Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/86/33/81a29b796dd52a45e9ef7974c7732926e8f10f15b8d2be505665979f896d/librt-0.15.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:856f743ae607f2c1380eccb566c0038a9fb3eabf0fc2be2704d76d9f73557239", size = 545890, upload-time = "2026-08-07T10:47:52.818Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/05/82/8be1baa1350e5d30cfd70ae79d0a6f4dc5862ef47f7bb2808aabc9bb86e5/librt-0.15.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:779a6e7c894737e5983e7790a9c78c4000c30e23c9aada08081bdbea53b0fa60", size = 523287, upload-time = "2026-08-07T10:47:54.165Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c6/4f/d1be6a01a35c20ef734e0e44113f87d4af756a9354a89dcfbe3b4f8af5e1/librt-0.15.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:96bb17dbe8bab3c0954fbebfc69ed395599de75b6bbc35e3270a878e15d4dd65", size = 565868, upload-time = "2026-08-07T10:47:55.566Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/67/88/649cfa33f5825927b160610f670bdab012a64d627eddb94fa795ea4292fd/librt-0.15.0-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:7220697efaa6e5348fc3d18ee7f8563d4bfecd9872b37ffb915bfc1d08840622", size = 81619, upload-time = "2026-08-07T10:47:56.886Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/22/31/8e88a8d5e48fc8d1a817787fb6811dfff6499acd6c8683dd83934aa6ede0/librt-0.15.0-cp314-cp314-win32.whl", hash = "sha256:f54598964d357b1c5ab77cf5d92f21e598fe0e23cdbe9618480807f81b4eba15", size = 100138, upload-time = "2026-08-07T10:47:58.093Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/80/92/20fd6c4b6a1b1a564b076d55cd3d427d8428217d7638dc25a654cc4791d4/librt-0.15.0-cp314-cp314-win_amd64.whl", hash = "sha256:3ff5893a2c23d886aa9ce786de5ac6ddc74aeeaf90743682b74d920e117d2e28", size = 121258, upload-time = "2026-08-07T10:47:59.564Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fc/28/6af430b44d9ebb897b865a3c363b6dcace51357be2347cc0f8f869656a86/librt-0.15.0-cp314-cp314-win_arm64.whl", hash = "sha256:3722a099730704c9a3d70c879fc0f51daec25fe5f1555672d97bc595abeafb95", size = 106467, upload-time = "2026-08-07T10:48:01.097Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7e/aa/b42bb798942ced219f6d63b27e07f91237887a8d0bd0921666db79a13790/librt-0.15.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:38c0c7d4b6fc06c3324b3f9162c8391bfc4fd9dde53afe1033ce7edb48d5a714", size = 159523, upload-time = "2026-08-07T10:48:02.442Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/75/03/1b53cd4ef904e73b1d828a5f90143bf94a2967d7cfff0b9ccf93e12aa9b4/librt-0.15.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:8b2fdd7ead3c995c37940a790690660d0ca006c302db26cc51933f6766866fc3", size = 161638, upload-time = "2026-08-07T10:48:03.725Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ac/c4/9f9c9fba097d49e9e694c2b4dc331df31884645ecbc58a93b4b5fc69d2c5/librt-0.15.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2fde98cf1fc4bac144ce23c2c4c017b924ba714509ea9334977b0b27050c837d", size = 701795, upload-time = "2026-08-07T10:48:05.135Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4c/05/0966840bda0380c8ae167b9043c6230202941cc90ea29c48e096964c765e/librt-0.15.0-cp314-cp314t-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:e3b461183c5fa7681b48560f91515f53a953122fb30c71e07abc67d7ddf58c38", size = 682147, upload-time = "2026-08-07T10:48:06.555Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/18/af/1c47ca573c30ea47d195aec26133af522fea1104afaace028d7b32247ea8/librt-0.15.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4bbcc257e3babea20a91715c361b24554ec4e8f51aa578568afc230799fe1a19", size = 696397, upload-time = "2026-08-07T10:48:08.03Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2e/0f/1aed6223d4f9f9d1171a8596ff100ea4c3f7699fea7a4ba657c3e60daa6c/librt-0.15.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b845b8d48088fad0cadc84be4b8fda63203be7e9237b71015b3925443c1f35ab", size = 722542, upload-time = "2026-08-07T10:48:09.569Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c6/22/9e3a929aea456c97d69e6ef3884efea56d4807f97399471cc946baebd8af/librt-0.15.0-cp314-cp314t-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b30e600e8f337b9bd7f39b86d9fdfedc73cc46e3d0f745931a23a234220bb7e2", size = 729709, upload-time = "2026-08-07T10:48:11.129Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e9/1b/c327ef6018e3a9ca0b8e7c5eddeeb331ba8f9b76c24e126d37d0f6d62faf/librt-0.15.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:64b0c8c35aa4c4ed79896359f3e0b285cbe4e610042106500da4811c322cc108", size = 752891, upload-time = "2026-08-07T10:48:12.558Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d7/d1/d5f1ea02c56930087009e39db9b70660a663e76c730b27b925d786718457/librt-0.15.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:0da0d94cb802f32a0524653e7201f2cef72d5f700a5407678f5290483d4fcd08", size = 745301, upload-time = "2026-08-07T10:48:14.55Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d9/3c/5f7c585d15ebb2250c73e7c0ee4e9e47be72c65d520c07ddbcdc62037674/librt-0.15.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:4a6369168d371207339b1e50d4532b06a7121586141f82599505a3f315751d47", size = 747921, upload-time = "2026-08-07T10:48:16.453Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7f/52/1443a446486eba966bcbca1696b472e4f210320ec42f490a47f48fbf0fdc/librt-0.15.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:c434e072557ade9cbc642d052c89d031efe47d5c9614523619d0d74a02378e81", size = 727561, upload-time = "2026-08-07T10:48:18.089Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/79/91/2270a9380f11725cf83ce1925a5e32dd1dde2be9bba597f25c10a38644e7/librt-0.15.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:c7eec6a42018bc1d45763b1c162d3d2bf7c3b9a1b0ed30d3e91dcba390efefcc", size = 774417, upload-time = "2026-08-07T10:48:19.611Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9e/3b/f4b1548d4f5b99186737fe27aec238e9823e8d5d23bf4df007c030689dc5/librt-0.15.0-cp314-cp314t-win32.whl", hash = "sha256:6912fa5e635d74529ac7cdb1bdf6ca3af4453da8d1edbe0110ee1cb4ad407ebf", size = 104381, upload-time = "2026-08-07T10:48:21.048Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/80/b6/134afad262def1de04c0843c376d02135f1168af43f22e09a52bd8394727/librt-0.15.0-cp314-cp314t-win_amd64.whl", hash = "sha256:8e11699ed745931c395acd3621b07062e0f840efa6935aad87a64ed0995f0915", size = 127034, upload-time = "2026-08-07T10:48:22.561Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/99/5f/1b6846b20572bd699c9e9ec321a5f781845bee477df2aa2a43b28bc40119/librt-0.15.0-cp314-cp314t-win_arm64.whl", hash = "sha256:5d2a91724463bfed4f573cd7a9fdc856d2e230d0c0e5a61416a93481dccd8605", size = 110827, upload-time = "2026-08-07T10:48:23.804Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c6/44/4de9f4ddadb009a55c7758eb5736d62534a7daaf27bd71bc50e64b606b06/librt-0.15.0-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:8443e38dcfcfdbcf5add5118c623efd788d65ac2e25756d6251a54a06a4d0aca", size = 149843, upload-time = "2026-08-07T10:48:25.148Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1f/eb/5d9ab71e30119c44094e0275f38b47dd327aea0f843a080396677029d508/librt-0.15.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:6d15a29033c57490cfe2069097c6fc4049e4e65ffbb749be7dc453b7c4c68965", size = 154510, upload-time = "2026-08-07T10:48:26.485Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d0/9c/8505d1b8f5e8c19587bd03f7429993b3e9ce5c06819d856bfb11d919374c/librt-0.15.0-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d2c05c729b589e734c09578bf5964be48a911765484840d017bbc84f49d4c4ad", size = 497543, upload-time = "2026-08-07T10:48:28.045Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1d/9a/3a8390775cb095765aded027ac9c63e7c8ea74e731498607544c6505de0e/librt-0.15.0-cp315-cp315-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:fa60887537e1d0cd2d9982269d33a709bf54b195cd2b9364fc0a758022af5bd9", size = 480452, upload-time = "2026-08-07T10:48:29.531Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e7/40/258a4a7117ee915d66de5cd9b8ade65a440993161107ce3a686f1859955c/librt-0.15.0-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d8bc24219b24c0af375718942ab75e3544b2763085f40f965be4326734ae8328", size = 507768, upload-time = "2026-08-07T10:48:31.007Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6b/c6/2f4dd296c97a0b85b98894519b279408ec9dd602d4f692b1ea0e25dee670/librt-0.15.0-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:86a21a7bd3fe3a419512ef424cc1c020f6771d0b29cfddff36d1635a855e63f0", size = 525122, upload-time = "2026-08-07T10:48:32.7Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/49/dd/29eab42be13b2bf0ea8cb227135a45d44693e30a7e8b92871981ff56b82b/librt-0.15.0-cp315-cp315-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:dbab647e88d90b3167b91efe7091e248653688ed4337e4f90907a722c7361bb9", size = 520371, upload-time = "2026-08-07T10:48:34.294Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/91/ed/4bad71adeca8fe208b775c2a35417fa5a2584c8f4791daaf89a89450fea1/librt-0.15.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:d8edcf6f550e918dca779c069b9e156385c60b406f99fc7641f32c52f7193659", size = 537258, upload-time = "2026-08-07T10:48:35.88Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4c/63/59dba6143fdcc7240c54458b629f3250000a61b8945890fc9efd451b19c5/librt-0.15.0-cp315-cp315-musllinux_1_2_i686.whl", hash = "sha256:8b62076030baa2d8b1501a46bf0e19c27a489aa90671c55665bff7887f7660b0", size = 527432, upload-time = "2026-08-07T10:48:37.466Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ec/21/21a24c6a2327d8362580efebe77286bf47b0f4062ec5ea41766e609d3c7d/librt-0.15.0-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:d00d20d1818e82a07a0ee0aa89a98b17ed7916b92441090b683719cb20a59b6d", size = 548108, upload-time = "2026-08-07T10:48:39.384Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5a/6d/fc68c89a7971418b41f9a873623ff935cb864097544c6a2f8ce491c8ef5d/librt-0.15.0-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:4e6ee93fc3cf848dcbf0cce2eca73d8e7dcd0cc2b6df3a529d57750b30a4c55c", size = 529681, upload-time = "2026-08-07T10:48:41.392Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/65/7e/c2d98766124400d722063a630b0fde38a9fc768705d37eecca15c47dc192/librt-0.15.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:32896a0af72508ea979e0acb4e4c04cbeeae04938167950d535c83c45597167d", size = 567736, upload-time = "2026-08-07T10:48:43.124Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/55/6c/f8c34a95e3a515c6e1c192b89511e7253c89a7760c6b500d57ffdb8d2dc8/librt-0.15.0-cp315-cp315-pyemscripten_2026_5_wasm32.whl", hash = "sha256:ec3ba415afaf951f6951b1dd16d3c8e4f540065fc382d7e70b823a79567ca374", size = 81673, upload-time = "2026-08-07T10:48:44.645Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c9/9e/e23fa8e78679ec45728188650b39e8ff476c83b691c96f749217df3b1b7c/librt-0.15.0-cp315-cp315-win32.whl", hash = "sha256:d2813ba2503764f0450680c533d13df7cff9b49df1411062eded5f67db4195b9", size = 100081, upload-time = "2026-08-07T10:48:46.171Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e1/dc/3eb4c5e297343f0620a55532cd7c8d764d3001fa2159212dadf480464827/librt-0.15.0-cp315-cp315-win_amd64.whl", hash = "sha256:b87d67e33afaf265262f2a66db578284b88ee2e6fcd224579cb5c15518677ad8", size = 121228, upload-time = "2026-08-07T10:48:47.631Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/97/70/43abce19f04e49762f8ec834c8fafee13cc40fd6b94a72a24e534febfcd0/librt-0.15.0-cp315-cp315-win_arm64.whl", hash = "sha256:713bd7df21170b982e729e46870f31d6b437bd1a9b4648cffb529bd3c2ec5c4b", size = 106487, upload-time = "2026-08-07T10:48:49.095Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/de/15/83f2deddb9368b8951ec8c9477269b5b9b8bd9bbf15e57402d0f38817dca/librt-0.15.0-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:3de789c82752730f94782a5ee518baf9c05edf85733aeaf73bb6e518755cdf54", size = 159448, upload-time = "2026-08-07T10:48:50.649Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/06/bf/043097353f9b3c73b583d07f6b8e552795463f4bfc8caf85e42eee50c26a/librt-0.15.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:e0b5deec9a8664eb722c797241970fd4aa1894d25fda36a1ddac0f7407606bd6", size = 161686, upload-time = "2026-08-07T10:48:52.174Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f4/2a/8ae77f9719d42ce71cd708560a3557b38ac3c17a0383e57f87084de45bbe/librt-0.15.0-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5563302a8359bc2295bb7084d1a8ed1519df96afb30eb2aa4e0bff7b54228988", size = 710668, upload-time = "2026-08-07T10:48:53.782Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/61/34/c0436ea134deb9a0d6da80a396a2739a81cb31e0418f7227239e23140898/librt-0.15.0-cp315-cp315t-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:22d6263b9d39d7bbb286fa791945646e3218f1be2d693e36fb630f1d0e59cd13", size = 679396, upload-time = "2026-08-07T10:48:55.645Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4a/9f/001e0d99aa9250d5cd5715a9081291a20656083459f9019cda15255329e1/librt-0.15.0-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:39ffd14646190c454f0d86e0d256b33f00a87a26ab410e619773b841d0e41416", size = 704313, upload-time = "2026-08-07T10:48:57.46Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2d/53/b34fa9d0ff00f136f4d58ebb4c411ff634baed1eb412bb602a2bc8dcafcb/librt-0.15.0-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c47318cd3a61401452de11282242937e3e057c4fd3dbaf601e269d0928a06c0a", size = 729847, upload-time = "2026-08-07T10:48:59.231Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/86/ac/fa4d7a424665040e95baf480a6d523446057684b6758624c85338e8a23b2/librt-0.15.0-cp315-cp315t-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a56a1d4f859a82ca5b99fc4b82c9b027b15e3c455c5cd99e7d0719f27bb20b6c", size = 742736, upload-time = "2026-08-07T10:49:01.151Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8a/f1/e17a9bb5de6fb8c3186ed1a7d68d21618b027ac2d3633e03d3b6109c67ae/librt-0.15.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:077471b3182db4e17c36ae91555f36a4d2c00080b267f749bcad34a478a9a302", size = 763454, upload-time = "2026-08-07T10:49:03.039Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1d/ec/ecd02cd30935b931b9cdbfed6ab5a099c51b280b4e7baa274da80978ed27/librt-0.15.0-cp315-cp315t-musllinux_1_2_i686.whl", hash = "sha256:411ca4d1b905b860ceba7570dd6717a71dedaddcc4b0f77ece710aa41ee11f8d", size = 743296, upload-time = "2026-08-07T10:49:04.941Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e6/b5/b3c2b8353ce820a4854f78d19321344242f89fa71c975b71132ba9bf242a/librt-0.15.0-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:1256589e0b0adb31751d685a68bce29d73407ddf4ef05d4188f49d5dcf9566d9", size = 756217, upload-time = "2026-08-07T10:49:06.825Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3c/52/6cc22542ba59146b05cca2a656f9ff8bb67e38e63d12c3b0cc183d837bf1/librt-0.15.0-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:f42b74a53e5f26a0ba0007411a7455b66c67ce4022a39cc1f56fc4efd65bcbab", size = 741934, upload-time = "2026-08-07T10:49:08.839Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/40/32/a04b72b1aa86e3be23b2ecff8c1aad2dcc955bd3956d6d26e7e34267e57a/librt-0.15.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:291bf73caf78b9e88d6fae9bfd693207ff7d832e2fdbe2cf8e746bc13f5f892b", size = 783763, upload-time = "2026-08-07T10:49:10.661Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6c/f0/89eb11dffbe9279ff37144dec786927314502ae0b114f1449dc78c458aab/librt-0.15.0-cp315-cp315t-win32.whl", hash = "sha256:c16d15ee371643ab48dc8248a3e680ebbeca573a13af2c3dd0c985b142d77162", size = 104313, upload-time = "2026-08-07T10:49:12.305Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6d/4a/1f1978c200f563beda63c36adff2d65bbecb81e365e8e69e572f5f70fbc6/librt-0.15.0-cp315-cp315t-win_amd64.whl", hash = "sha256:dbd605739f228912dc49027cb764456b9757750bdc2b6b7773164db7096c6fd1", size = 126889, upload-time = "2026-08-07T10:49:13.881Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/38/a6/800800bfed7b1fb10fc3f3d557785c3854e80d3f7a9800d784b176a1fc2d/librt-0.15.0-cp315-cp315t-win_arm64.whl", hash = "sha256:84d244b00604d17df3fc7736c327892d6bba66181254aa4087be807b6c342bdc", size = 110700, upload-time = "2026-08-07T10:49:15.499Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "mypy"
|
||||
version = "2.3.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "ast-serialize" },
|
||||
{ name = "librt", marker = "platform_python_implementation != 'PyPy'" },
|
||||
{ name = "mypy-extensions" },
|
||||
{ name = "pathspec" },
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/82/6a/878cc1097d4035f82bd516658d0c528d2a9955bc7b363afcbd0b07fea11b/mypy-2.3.1.tar.gz", hash = "sha256:47c1b1207258513a9d93495f69c8be9de73916186f0e52703e8c461b7a623419", size = 3992554, upload-time = "2026-08-15T03:03:38.549Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/85/da/d6effc4f808a842d91edc22535dc9e799d2ff6e91449168b7f47a0771f54/mypy-2.3.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:a32bbbb940af990d3be0b8af321c7b6815bb1b3b48142fe7459b9cc5f58959ff", size = 14047547, upload-time = "2026-08-15T03:02:57.707Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e4/e6/478229701dab76f26485fc8ff5d6f241f393da22447400bbc56f6946aebe/mypy-2.3.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff715e45b2231a8e85de1d163d1b42791e4d7aab8f5145f85fee1b710b735aff", size = 14216515, upload-time = "2026-08-15T03:01:26.496Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8d/fe/7c42327a3b21e84681f691982cbfe43f334a3685f3b683b72c376476c4fa/mypy-2.3.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:858fc57d3d91fa728e33e7ad71def60fc6272694607b306cd3292db53ae39080", size = 15307789, upload-time = "2026-08-15T03:03:31.62Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/59/f4/7e597edbe01b5a56fa958ce541302dcaabfed979966f1dffedbea0ea0fc2/mypy-2.3.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:851833db876e7b650f93719c74b7879a08e338979c96054fdfc3bfd90a486355", size = 15548831, upload-time = "2026-08-15T03:03:15.55Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a3/52/cb31e084bc0314a1e384bdd677a4b80e55af04ccac077545e2238b9d320a/mypy-2.3.1-cp312-cp312-win_amd64.whl", hash = "sha256:4c5095a327483591c94e0c8d3ef9e50d4ab1369b541eae007c1f23bc2a41f6bb", size = 11226359, upload-time = "2026-08-15T03:03:29.002Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7a/47/88fcf6217b43fa2da81a8c2611370af18141536a4f0294bbf98b457d456d/mypy-2.3.1-cp312-cp312-win_arm64.whl", hash = "sha256:bbfe022634a2a195406bd469e888d2eaf193b02ba7e607391cd7640374aaae3b", size = 10214707, upload-time = "2026-08-15T03:02:48.807Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/de/cf/862010ee800ca9c2bd0c4c0dacf0f092e5411824a09b8f97ad4be8fe250e/mypy-2.3.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:114dff494000f18bd10d5d95d84b8567b26da60279ecbe838131841df20e635d", size = 13964542, upload-time = "2026-08-15T03:02:21.43Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/75/5a/3f3a2107b41e3e92e617e25daaee121413b91e9784bea733131ed4fecc5d/mypy-2.3.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c8637731bb5eee3671eb2c3200827aa3564ed8a9309ecee4d1afe77e6d031bdb", size = 14168922, upload-time = "2026-08-15T03:03:00.351Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8b/41/04dc4fe7e63d7820fa4eff272e95157d30cbea921388f3ab3fe77794cd0b/mypy-2.3.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1c80fbc405ed8020f5ff3802dc18cf060197bcdd3fbdd6a26ef2fd34dfdd5226", size = 15244791, upload-time = "2026-08-15T03:02:31.089Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/96/fc/c3053b26b9054949285aa868cb6af8c10e7591541cacd79c5dcc06a1fcf9/mypy-2.3.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:84081f538ce27375045c02e3d7f81bd11d853400621ae245d87ce7b6c420ec74", size = 15501627, upload-time = "2026-08-15T03:03:34.128Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/70/4e/d77daab008bbc4e5001374d7928f4a260d28f0e6747af444fc4763f7a310/mypy-2.3.1-cp313-cp313-win_amd64.whl", hash = "sha256:e9144ac16fde007096f9563eb2041b4433c2d705c4218edeb79e7e9d01035ee6", size = 11243961, upload-time = "2026-08-15T03:02:11.952Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f0/f8/7eb68c136e4abd30569fe31ef2bfcb7eceae9952cab80017c04cd09f5d0c/mypy-2.3.1-cp313-cp313-win_arm64.whl", hash = "sha256:77ad9529e67dca28e511f5cd5671436584ce91f6d3bac159a353158187b986ac", size = 10213219, upload-time = "2026-08-15T03:02:26.361Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/be/c4/42a49d44aeff804edf1b19acce0b49e8bd1a9c57dee9605dd8d980aa43d7/mypy-2.3.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:192abaedf75da1bc0b1cef104927e70ec49c1ef0031cc4825c7ee10a438ed24d", size = 13986778, upload-time = "2026-08-15T03:01:33.69Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/45/13/9331fd2dfed7194d66c5304072894a8be3e51e9deda6863c1eceaa35a43d/mypy-2.3.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bf678dffd16efcda2c15cbd30e9ecc0081388e29ea23687a88e686ed92638dc3", size = 14188467, upload-time = "2026-08-15T03:02:40.554Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/78/f7/f4a34edab45667c5465855dc585a20e87978ffa8aee711445b7239d120c6/mypy-2.3.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8e036f06b41630f4c8a1d48f9ac6aa26acc65f8be089973f5519da643318f03f", size = 15225538, upload-time = "2026-08-15T03:03:09.761Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/40/05/534b3590757bd05794f73e07f6666c2a77b8597ffed795c94ce570096aa0/mypy-2.3.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:71af9c8a894e862b58e92abb08e53b05a384a1e5e5d6dc7cda59126211a53d82", size = 15480805, upload-time = "2026-08-15T03:01:41.134Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/55/da/bdfba852e2562f599624af5bb7d29e36b0b4f526f2b8bac85efe0dd1803d/mypy-2.3.1-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:3c80cd23d85368bdd9f37d5231dfd97d35bcbf5bf41af96ef3a9b078ad1957f9", size = 7761712, upload-time = "2026-08-15T03:02:36.008Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/98/31/60fc64a74cdba4f2a5d642d32317993e479163e1ac7d91b695e5d15e2264/mypy-2.3.1-cp314-cp314-win_amd64.whl", hash = "sha256:4956f34d145e145562a0a0bf367f642bbc85c04ec2baf47ae015947c3169a85d", size = 11423968, upload-time = "2026-08-15T03:02:06.931Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a9/23/eb5950b24cd26ba3b78f87707a275568d633c77dae8e61c9661be6055ca6/mypy-2.3.1-cp314-cp314-win_arm64.whl", hash = "sha256:cfb12e360242d23d91f5e978d94f58ea66acf5804c4fb6f2f794a20d4cb1b595", size = 10399323, upload-time = "2026-08-15T03:02:33.671Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/82/c7/f80f4e46c0b9a00eb5f78a79d49dda8bdf56a5230f7257fb33e76be04da7/mypy-2.3.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:e5f1c50bb05b64e2026b52867e8d21106f01313c744a2c4ecc34c90d12e8d6e2", size = 15121308, upload-time = "2026-08-15T03:01:46.053Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5d/74/9b04f17c7074cc5188f02fb63a2ca1d43fedf479e84fe3091c39061a1d7f/mypy-2.3.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:667196b352f4cf304ded4c10f90cfc179263a1acfb3cdcfa984bdfd340d498bc", size = 15536590, upload-time = "2026-08-15T03:01:35.941Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/26/04/c837ef6208e567774e2ed1f863f8ba6ec4817b1b6dd426315e5d559b6ec9/mypy-2.3.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b9c53e395c12cad2c6d4b67d5da7c6057638a132d85c08b73646b18f802a0045", size = 16791074, upload-time = "2026-08-15T03:01:31.073Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/37/68/48730230afa45192d5bd429a6a2ff24a6f8dedda90fdf2b221792b54518f/mypy-2.3.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:18162b128c3f9c703cd35f5537446900b0d21a2549aa7a95d21380d2ef643fb0", size = 17069183, upload-time = "2026-08-15T03:02:28.566Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1c/ea/ca23fc9c20eeda09a15c9cbcf50015d0e73f409f6ead059e42aa69a608ff/mypy-2.3.1-cp314-cp314t-win_amd64.whl", hash = "sha256:30c0477d4aab7b7f39c8397dc877f2c96b9fe5588ec379f372c56eb63d599f63", size = 12154679, upload-time = "2026-08-15T03:02:04.809Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3b/67/8d982126034990869466f73b8db80dcb2234a7ac39b4dad093e047a79835/mypy-2.3.1-cp314-cp314t-win_arm64.whl", hash = "sha256:6941ab3619377bc3f32ca02876b07d27f216f5201604b664d3937ea0fdd23bb4", size = 10969159, upload-time = "2026-08-15T03:02:38.152Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ee/f7/41e7f2d8117fbc7a7587286162ffe2f688984b69c46ed63cf5f2e4fc3bae/mypy-2.3.1-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:6f041a6de52c9217ca125e78ba0a335cb7fd98a1c0580978e49ab2b126f70b57", size = 13990694, upload-time = "2026-08-15T03:03:21.919Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/06/85/8f665811a0c8f3bf6fa1d9acd665ec2d97a2bcc453ae68dcd92340941cd6/mypy-2.3.1-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5159ae60f5dbc3a498af5ba8365505808ac8031bc63f9e00304ad545d40bdd9b", size = 14203518, upload-time = "2026-08-15T03:01:48.455Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2d/82/91b866c8546b120bff83b73a439d90d2d63ef3aff113599e6b8e4d566848/mypy-2.3.1-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:47a8a7a0a7f6f6e63995c0ac36fa0c07b127413fdc81f0439b7f3dccafd33561", size = 15220224, upload-time = "2026-08-15T03:01:23.577Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c8/78/c226c99208ee40de7c768369fa533f933afa003dfdc606ff021450724e91/mypy-2.3.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:2329c0501293d4e1f33bc15d04d6304d65a1cdda967ee93a05c1e681a3923133", size = 15501512, upload-time = "2026-08-15T03:02:09.453Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a9/e7/7cfb3f106c393979f4cc37ad6c0586044d50401e3c35b0c003e4f3ba6bc9/mypy-2.3.1-cp315-cp315-pyemscripten_2026_5_wasm32.whl", hash = "sha256:bb26deed807bdb0457cf3e3f1cd7c4a1cf9d66864eaf1b4a61e06805d4c6b1f9", size = 7761913, upload-time = "2026-08-15T03:01:55.65Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/99/3c/52affefa273b97939a1f474ae4a349c8718635c15b941112dfab4291b0c1/mypy-2.3.1-cp315-cp315-win_amd64.whl", hash = "sha256:375d7013876a8233b2d05be185bfa09f689696cd999ce8b1cfe6acac5c80e8a3", size = 11422533, upload-time = "2026-08-15T03:03:24.101Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2a/b7/75643e70c72a5b346d8a9b1543c967ea8824df2ee3fb7ccba652c272b7bb/mypy-2.3.1-cp315-cp315-win_arm64.whl", hash = "sha256:586b3612214cceabb3c0f588c97e7d1e535393f06a60e912e994f6b3ace97523", size = 10397931, upload-time = "2026-08-15T03:02:55.265Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/10/ce/53be21f2d4adfcd26f63f1184a13ed797015ab463853f117e2e11e4d726f/mypy-2.3.1-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:ef0c6335cda9d807f8193d8ff6204a72bc909fa9882aacbca14f43cdb7188306", size = 15118669, upload-time = "2026-08-15T03:02:51.479Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/62/43/20de757cd42989d291a17fad607742c4c74e875ce5cea00e5a5225020ac1/mypy-2.3.1-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e598c8c66401d26b150872154a286e6d484cf2789c3bb28a7556806298423021", size = 15545627, upload-time = "2026-08-15T03:03:05.132Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7e/fc/092bdf77ad280eaf501422f0f3b966012b528076cc13e41a774861c907d1/mypy-2.3.1-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:eda22fd4efa9dcd39331d1dede9b5b8b8a7fd69af07592e778433da98610d29e", size = 16764157, upload-time = "2026-08-15T03:02:23.958Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/94/5c/c94c4d62d909b07f552d0d9356d7acc943825558e602a64822ffa2231536/mypy-2.3.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:2a0ba2e57847849fb0d1fcdabb32786d223095ed8bc121dfe322bcdb3d9c46bc", size = 17073258, upload-time = "2026-08-15T03:02:14.573Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c0/f7/511a88b89e478053c02d22039bb8f3ce4183efe8fd7a4f0a5910a8bb0a32/mypy-2.3.1-cp315-cp315t-win_amd64.whl", hash = "sha256:3f7e865dd51f235f60a2dbcd8728a1c095f5ca28f095d48a725b84cd935735c4", size = 12135505, upload-time = "2026-08-15T03:02:16.714Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/71/bf/02573b56964ecb0f7c644f915f53c325ae15c3faec521c5adf11599a32df/mypy-2.3.1-cp315-cp315t-win_arm64.whl", hash = "sha256:8ad80807dc3ab8ea978b1b2b6e4a657194ace1d4ef03e0e731aff1abd517da29", size = 10962647, upload-time = "2026-08-15T03:01:43.712Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8e/41/9675c7a1e78edecfba0b79e587a52594c56e189368261dc7b3a7fffb9527/mypy-2.3.1-py3-none-any.whl", hash = "sha256:6ed5c7e3419083268e5c9258bd1c1ef91af44a9e89374dbcaf37b775716e72eb", size = 2754338, upload-time = "2026-08-15T03:02:53.4Z" },
|
||||
]
|
||||
|
||||
[[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 = "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 = "pycparser"
|
||||
version = "3.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pydantic"
|
||||
version = "2.13.5"
|
||||
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/53/ef/fc4f868f4e2cee79f863883abffceff107875f569b848507319842d2a681/pydantic-2.13.5.tar.gz", hash = "sha256:51a9c5f7b2f8e636f04c6cada605d9b6a3bf1348fdf945a3d8869b19bba0ee08", size = 845750, upload-time = "2026-08-28T14:04:00.916Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/eb/47/c95ffc2009878c7aac0c5e08528022dcb885933252a88b5f170058014464/pydantic-2.13.5-py3-none-any.whl", hash = "sha256:346a034f080da3755d8e9cb5e00e8b07de1d39e4f6e2c87d8ab7cafa0b269a73", size = 472589, upload-time = "2026-08-28T14:03:59.136Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pydantic-core"
|
||||
version = "2.46.5"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/af/f9/8a06bea35ef8daf588f707784c973a7046e0034c8d8cfb08828eeffb8b75/pydantic_core-2.46.5.tar.gz", hash = "sha256:10416c15b8839ecc4ef4d0885da76da6fd0f67333a0eb8aff6d93c4b8f2910fc", size = 472262, upload-time = "2026-08-28T10:01:31.677Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/82/3f/76358795aa7a8c6d4f36e2cb828ad1c90ee118e1393a9281664f5aade9d4/pydantic_core-2.46.5-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:b9fe6fb92520e3fd61f2e49000b6911b188824f089b75973ea06d6267f0b476d", size = 2076516, upload-time = "2026-08-28T09:58:21.576Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/db/50/26b091836076ce4cb2fac264186936acc069e0595772cfd02a563bc4761a/pydantic_core-2.46.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:a39ac25a9a2fa4072efdb429833c4a4c8009a51ff9eea3eeae131713cd27991e", size = 1922874, upload-time = "2026-08-28T09:58:23.766Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/09/f0/2a8ce3849e299d44e2d2c196b6082643a3235565a735cb51db7a6261f614/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4fdc8b93a41521988916eeaa271173fcca7fa0803d62f87675aac8dcec1c8e29", size = 1951772, upload-time = "2026-08-28T09:58:25.435Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/87/46/ac0dc8bdd9e6048183a14eb127764e7ad9240021c17513074a4711b0e31e/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b98134087d9de723658d17a42c7d0da8d6e2ef08015dee7dc93889047315f5e4", size = 2031832, upload-time = "2026-08-28T09:58:27.102Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c4/c2/339de5bef7be36301a2231eaa52e62163742c2281f11b5f4892bc79785cd/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e652ab17569c94bff5475520f907b7148b8c24036a8ebbe5cf7cf7493d28579a", size = 2208645, upload-time = "2026-08-28T09:58:28.948Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7b/a0/9ff22b797724262da14427abaed4dd1d864a139693fc5e7809114376a716/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d925f3d9afd05a8c0fb3a1031463a8d59ebe5e2afad297e29c78be19e13b4e62", size = 2265935, upload-time = "2026-08-28T09:58:30.625Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c0/a4/eb9409ec0736e50aa70a412f16c204ed149516846912f7e6724d4c73ee53/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0fc5be0abd4a407e200d844b404e33639a554e7bd0d448e7b9ae181be4789ac2", size = 2066284, upload-time = "2026-08-28T09:58:32.289Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c0/02/7f6156ffc926857f1c37c07d9a388682865a81830ab6a1b637082c25e399/pydantic_core-2.46.5-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:816ff0a6550ffc06c098ccd2e0698600f9aa7da192a79eaa6f9af504a35db869", size = 2105889, upload-time = "2026-08-28T09:58:33.986Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/92/b1/e781d357ebe09fc929f995700f1b3503e8897f1cece183ecb1300d4d67e9/pydantic_core-2.46.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c7ea57fc63aa7da93a1bd2d644e6577befae10c52c4e36377635eea1056a74f5", size = 2158006, upload-time = "2026-08-28T09:58:35.647Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/70/0a/644597d84ab400e50609c192120b85c9681c22d3a20461b9060a79be0a7a/pydantic_core-2.46.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:efd62a42486f1bda5d24cb4f63d15a3c7768375fe83d36f9417b4ad7a2fb20b3", size = 2158408, upload-time = "2026-08-28T09:58:37.38Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1e/ee/ca3b7b3a4b3769ffe9ce9432a7c9be755de9593a46d3b0d54d0409323e44/pydantic_core-2.46.5-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:2bc9419666990c06d7397831f2126a1ecc3594aaa3ff7de5bf2d066802f4e07b", size = 2309609, upload-time = "2026-08-28T09:58:39.22Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ce/52/39fa1f451486019524ca685020390e7ca351832fd874530ba30c8628e6dc/pydantic_core-2.46.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:18a09e1e1011b462f2e32774f25859ef1223d5c2b0546a633cf56654710721e0", size = 2342618, upload-time = "2026-08-28T09:58:40.89Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/81/5e/468fc630568c61dcef3cd47ad32ffbeed9af643f49208d1ea86ab4f890c4/pydantic_core-2.46.5-cp312-cp312-win32.whl", hash = "sha256:5cb482e9e84c851f4e623fe4acc1ced89168cf1fe18f7089db4548c8f5bbb65b", size = 1939475, upload-time = "2026-08-28T09:58:42.591Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cf/c9/4c19f41b84cf6b622a72fbeed7665b25d47a187d68d47d0d430c07f23268/pydantic_core-2.46.5-cp312-cp312-win_amd64.whl", hash = "sha256:5e81740c09e310f5aa5cbd3e434a01c154d4bef93241c7877b39f211d2b78ba8", size = 2043140, upload-time = "2026-08-28T09:58:44.272Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/af/dd/0c1a050299147c746e5256db16d645ab5efd4f78c59937d581a0524e74a2/pydantic_core-2.46.5-cp312-cp312-win_arm64.whl", hash = "sha256:f7b0ec93a2893de856652154d73b7ba622f26fa97726487dcac373de5f4c6084", size = 1997729, upload-time = "2026-08-28T09:58:46.13Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f5/37/5abe39a8372a61d3dc3c1338fc504281c01b32fdb3169cd7187153b56d3e/pydantic_core-2.46.5-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:b7ca9034437b6022f941f4857459562ee00a560b97e7cce8a0ec5a74fc6766e0", size = 2075885, upload-time = "2026-08-28T09:58:47.856Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/21/43/6323b1f8b217780454c61304bcd2b38ae4762f50754414124603ccc90bb2/pydantic_core-2.46.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f332f0e72a5a0400141f830744e141bf9f97917878dbe968669e8a7fefea78ff", size = 1922768, upload-time = "2026-08-28T09:58:49.58Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0f/a3/c05ca796e1197618a774b01e596aeedfefc2f7d8c01ae3054e910b120e8a/pydantic_core-2.46.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:193375f3548919d3f0b60936ca113ada3e38f264f91b9b8e0508efaad57be931", size = 1951241, upload-time = "2026-08-28T09:58:51.511Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/68/32/33bc39ac705c52cffc908e8389f9754fdb208aea5c69cceddf4eb3ce99af/pydantic_core-2.46.5-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:79bdfa52f843137045b2d081cc05c120ba6665d29b7559c2c47690906f39279f", size = 2031975, upload-time = "2026-08-28T09:58:53.166Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b0/70/2333e885c0f6a67bc105c5916965dac9b57f2718ee20d81d1a06a4ebdc13/pydantic_core-2.46.5-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:24922243639cbdac66c75fcb6fd6495a9cb52b213d62f9a0d16f0310b1ff8038", size = 2208542, upload-time = "2026-08-28T09:58:55.017Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f7/ea/296debfb4264207bbda5936133892e027c0a58875ad53ebd512fba8ec3a2/pydantic_core-2.46.5-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c76fe65e607be28c7fd4d56fc3c42b1583aa058ce3408b7ad0fd540171d31f9f", size = 2264692, upload-time = "2026-08-28T09:58:56.767Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d3/f2/9e4de77a6271e07a76d2d58b11c091a979c191ed2939bf80067568b369d2/pydantic_core-2.46.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6f7b393a8b3da82f5c1fc0751e6d01ac6c55b93c18226a60bdfba4a724efafd1", size = 2066633, upload-time = "2026-08-28T09:58:58.531Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8d/db/f9e9d0c97445987b2084823d5c240de88087338f04fc2cfaa2df186b8049/pydantic_core-2.46.5-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:7ac031912d54f3d83ef3b3eb98dfabc1608802e2202263d25957eeed40b94761", size = 2105235, upload-time = "2026-08-28T09:59:00.421Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/07/c5/79169b047b3b2c3e99e04bc76372af9637e0bf6db638274fa927df96369e/pydantic_core-2.46.5-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:837b396ca3d7b74091ca623f6cbd8351bd42d670a79c2683e79fb089f06a2de5", size = 2157367, upload-time = "2026-08-28T09:59:02.442Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/26/b5/ba6057afb7c291bd449f51b867f95aef2072941c4ce4e5c31d6ffd132d3b/pydantic_core-2.46.5-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:5ee239d575f80b08eca11f6e20f90c4c695de7825c67eefe6091fbf20dda648e", size = 2158420, upload-time = "2026-08-28T09:59:04.2Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6e/28/2057abecaafdc22912afa819603a51f0a62d40643b7c4871c51721fea9be/pydantic_core-2.46.5-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:e80675d75ae2cd14372cb65cad5400d9347a3d3f6c13000183f22dfd027283ed", size = 2309588, upload-time = "2026-08-28T09:59:06.048Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/71/9d/881156dc404e27479c4246128d73538464cab4a239bec61995e227644c30/pydantic_core-2.46.5-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:9c4b71f10dd532fb7a5cbc8f58707779e64f03a258c2bf8bfbaecfcd9970b519", size = 2341866, upload-time = "2026-08-28T09:59:08.539Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5a/38/d66f443a259f84d13babdceae568e572b0ed26da17ca5d0a649ebb110a67/pydantic_core-2.46.5-cp313-cp313-win32.whl", hash = "sha256:97bf8de4d541598c94a59344eeb988a94c08ff76b5723c41f6567ec18c7892ea", size = 1938580, upload-time = "2026-08-28T09:59:10.402Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2c/1e/1d5371213f4cc9a7ed70c0bfcc7911de22311ee99a662a56077d7292d2ac/pydantic_core-2.46.5-cp313-cp313-win_amd64.whl", hash = "sha256:15f4a94963c95accac15b7b657bb177d3ad82bb90b0d0526d9a9b85079925db5", size = 2041980, upload-time = "2026-08-28T09:59:12.396Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5a/48/4222d90b1c67568bace4dec6dca6271449c66de3595d72b6d098f5fde597/pydantic_core-2.46.5-cp313-cp313-win_arm64.whl", hash = "sha256:d22a945598fb91236b4dd793a6e42e4f3dd7740bb5aace5ebd7d4c08d13bb575", size = 1997213, upload-time = "2026-08-28T09:59:14.245Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8e/8a/14596f2a8367da50cf7cbac48169ee5d9c8e11d486a3b527082384630c72/pydantic_core-2.46.5-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:c1c43ad4339643d70ebb8124e1305a7dab423001eff58bb41a0f731adbc98355", size = 2074081, upload-time = "2026-08-28T09:59:16.141Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ae/d5/d8a4eb6d6c7f66b91dd37c576d76e9e60fba900caf5372c17bcf949febc2/pydantic_core-2.46.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1a353f84de772f423b5ffb11d7ae352fbbef0f446f3c0b0af0f8236d7233606e", size = 1920497, upload-time = "2026-08-28T09:59:18.065Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8e/26/092079428f86e927e030b2c0ced87df69dbb1c875cdeaa67bf42ea2be746/pydantic_core-2.46.5-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5086029a57366b8cf81b130a43908738095c270c21a8d7f0e8bdfdb89718e2f3", size = 1952130, upload-time = "2026-08-28T09:59:20.476Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/08/c3/8ec0e290a9ebaebd64047bf5fda94be835c6b1551b02437e4b76778fbcd7/pydantic_core-2.46.5-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:46c25dda9d092a06c08db76ffe0a197107904d0dfac653f7d5306bbcd6d6119c", size = 2026371, upload-time = "2026-08-28T09:59:22.227Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/01/72/4fd20ad520fb8da0157f95b27a7eb05a72790ef08138e7701ac972c342ea/pydantic_core-2.46.5-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:37ea7b83c935e5b0d68c9449b82651accf78a10828b2c02b2f2d9e9496446c21", size = 2202822, upload-time = "2026-08-28T09:59:24.277Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/31/b0/d16e0771206b29314f0d52198b720be21e8a99ab2bf11e3bc0d7c9cebdff/pydantic_core-2.46.5-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e64e88d5585bea9ce95861079de72006c7fa6d3df4e3a3b65ba31eb979c15c9f", size = 2262756, upload-time = "2026-08-28T09:59:26.608Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2c/9b/59634b7ac631c63b2a37760eb6943af3e29573d6b59a4abc5e7f019d4cee/pydantic_core-2.46.5-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:54d510bac3ee52247af28ed4bb18a1e799f040ac60fd2bf5ccd4c92f1fbe786f", size = 2068352, upload-time = "2026-08-28T09:59:29.044Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/08/7c/570abb1ad2155348dc754ea91be22e5aaa18eb6d69a6068f7c6f2679a6ed/pydantic_core-2.46.5-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:a2a5e1d0ff29adddc9f6d6821a66302e4493f8ca898b715b6b1182c2c201ea0a", size = 2104777, upload-time = "2026-08-28T09:59:30.95Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8e/25/5bf74adc65a1ac5b7be3f6cb0bcb5433615c1598a801c19d830d84c98ded/pydantic_core-2.46.5-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:03b9666e41e35d8909852ba191a0607520f81b74eaf12ccf8737005dbb313821", size = 2156312, upload-time = "2026-08-28T09:59:32.604Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/90/6a/2ef38830675e050121040618135564ed56b860b45433b02d9b4ebece46f3/pydantic_core-2.46.5-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:a91c17edf6eea2402cb5457b4c89e99bc5ed1004aa34c4adf1d4258c1a5c22c2", size = 2150067, upload-time = "2026-08-28T09:59:34.453Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/90/ef/a7dbb03a14a64c2a4621f989c615ed9a892535a6cad938fc27079f919d80/pydantic_core-2.46.5-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:b49924c73a235e969511bf2aabdff3beebf9820931f646c80274d5d780010c47", size = 2304516, upload-time = "2026-08-28T09:59:36.194Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/68/f8/6bb4c4b80e8a6fde1904c64a51c62a1d04fcdfa3ea521a66b2ddefa1d885/pydantic_core-2.46.5-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:2cbd9a5eff05e51c447c34dfa4632145b26b09120cf04bd0c871e44c1a5e1c9a", size = 2335223, upload-time = "2026-08-28T09:59:37.931Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2a/80/f46b8c681195190b2c1f1c7c0a81abce60663e987613e09ef64d433dd96b/pydantic_core-2.46.5-cp314-cp314-win32.whl", hash = "sha256:2d5d76654becf5efd62c9e51c3756c67b49498b0c9a40884934c40807adbd074", size = 1934827, upload-time = "2026-08-28T09:59:39.836Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f7/3c/60674207246bc0a4009d2391b7c7251c7159f279c8d2ab8aae8ef46f3dee/pydantic_core-2.46.5-cp314-cp314-win_amd64.whl", hash = "sha256:fa10ef4112775900e7a0661068635eb67b2ab824fbde764de6e0e21982a93db0", size = 2042648, upload-time = "2026-08-28T09:59:41.792Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/69/0c/117c562c7c1babdf44576b72a5e496906506c93690387ecfbca7c729ae2e/pydantic_core-2.46.5-cp314-cp314-win_arm64.whl", hash = "sha256:045ab3b6d308439e32b81cc173bba5b9018bc6ed896afd0c65b3b009b1699af5", size = 1989652, upload-time = "2026-08-28T09:59:43.702Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e8/66/9336ae58f9eb68c41d121894e52c4c89eccb07eb8f602a04ee9c3f37736a/pydantic_core-2.46.5-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8816f3d218beb4b787de5c9759c259b8fa61f9dec42dc7811f320a33771778b7", size = 2065829, upload-time = "2026-08-28T09:59:45.364Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c5/02/bc19b47a96c2d3109760711acf22369e56bd7e405ca52f7ade164d2ead57/pydantic_core-2.46.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:bce57638e08ac148e5778cce7feb968307a727d66f8e2274a543d0cf0c9ad6a3", size = 1905716, upload-time = "2026-08-28T09:59:47.18Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/52/a4/70b47c0509923dd98ccfed04fb3e32ea3849c82a0ff2205bb41009b43c00/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:976e1128455aa595ea04c79ccfedff1aaeab96ee013fcc916bed120c4f0ad94f", size = 1934216, upload-time = "2026-08-28T09:59:49.241Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/52/ab/aa03b65f7bb198585edf806b906c3223ecf1795543e39e23aec4cce27ad2/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e7b891faeedeafba41b2983e5001a81b6a915b69544c7e7570d1989ce1c36ac7", size = 2010635, upload-time = "2026-08-28T09:59:51.692Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3c/8b/0da06343f30b84ec549aafd309c6456223d5dc8bd36af504c573faad561d/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5f194189415698233dd1114a093a9b56e61e2c57e11b469be3b0506f46f0771c", size = 2209369, upload-time = "2026-08-28T09:59:53.582Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d6/5b/844c4defaa34a3df66eb9257087d121d70c201298b96abdf9f492fc2f1bf/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:82a36973cf8a2ef5406f4fe2edbf8ed0c99629535d959e0b100c76a32535a111", size = 2253238, upload-time = "2026-08-28T09:59:55.484Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f4/64/a4e536cb16d7f61a7fd3120b46c577fc7fa7325992f69c4f52bc786d77d8/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cdbb78909f52b981d3b2d56b97328d71eb0b974c36bd77c920123a7ebb192829", size = 2065740, upload-time = "2026-08-28T09:59:58.038Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5f/75/aaa38c6bc2d085f6605b34eabdc6a8a4e0b2e61fc9c8e6e52b28e97b3125/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:52e24eacdb536cade636aa90fb851835222becff8484b7001fdc78cb0290f2aa", size = 2087425, upload-time = "2026-08-28T09:59:59.898Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/55/ae/fcab4cfc39aba3689e1d20c8b5250ad280957022c09af2ed9cd585602a5e/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:37ae34309d7bd8c0d61ab839668058f2a7962ea1fc51d105d2db228fe0618034", size = 2139306, upload-time = "2026-08-28T10:00:03.057Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2d/f4/f1d03a4bc9d9acbc62f4d742b8a319af52f71885079868b2ff8e48a651ee/pydantic_core-2.46.5-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:0cdbada856a1c69a7624a64d3d9aefe79300bd6ef827b43a4f265010b9b55184", size = 2144589, upload-time = "2026-08-28T10:00:05.645Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/83/f3/7a53bb1356de514a4cd295f25b6ac39237895620c0462d2592b76c16e114/pydantic_core-2.46.5-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:545f26c504b27c3758439a5e6d9349931f0a04f855668d5fe323c89e82300a38", size = 2288882, upload-time = "2026-08-28T10:00:07.931Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cd/94/5a81583660c175c59d49ffb09f4b3a44debeaf86a19fca664ae1cdd9ee32/pydantic_core-2.46.5-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:ff218293c9c806138dca139765e3b067621be52bcd93cdc14c7711be7ddc90a9", size = 2335210, upload-time = "2026-08-28T10:00:10.177Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5a/9f/5d685c2693b972d1a59c998586e8823712b66603aeff47ee60a4bdaafd37/pydantic_core-2.46.5-cp314-cp314t-win32.whl", hash = "sha256:97cf3eb53a8cccacf9d46686a0926186c9bfb5574f2ed66d3639d5fe117cd3a9", size = 1921180, upload-time = "2026-08-28T10:00:12.35Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/70/12/5c94ee16d65a37a15f9e869f5e6256df111154491173801a4c5e800ab548/pydantic_core-2.46.5-cp314-cp314t-win_amd64.whl", hash = "sha256:d2f9fc07a8042a8f95925b35c4f04f469707c981fc33245b6ca187cf5d2dd290", size = 2020515, upload-time = "2026-08-28T10:00:14.774Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/63/19/67830dda664e6bdf9285ee2e40f355d0d7d6b92aa0c42e8d217bb8d33d36/pydantic_core-2.46.5-cp314-cp314t-win_arm64.whl", hash = "sha256:acf8a67ba51f4ca9ddbd0e6b3000a65ac51ab734661778b3e7ba64d99a710f2f", size = 1989276, upload-time = "2026-08-28T10:00:16.984Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/df/dd/053c2e4303f791f3b8f8a14ab0b22008e8eb21d868c0c90b4f9be705b76a/pydantic_core-2.46.5-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:013d6f3483d81e02e7c328831808f336c8596ee33b4bd4026b9ffb1e960b8942", size = 2062540, upload-time = "2026-08-28T10:01:00.318Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d7/dd/a18df751a5e37dd51bfad7f68e766999125bebe68c9e1d10a493ad01bd63/pydantic_core-2.46.5-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:e9c134bb666dd54b778b9fc0d2b50cbb7f979b9e3716f26a88c9ab3b6fc1dd0f", size = 1902040, upload-time = "2026-08-28T10:01:02.529Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b7/13/01d40f9d07ce8a779fd6e0bd8ad4fba91309500dd67b869e2e219d261a6d/pydantic_core-2.46.5-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:347ec774390c87326a2e4929d58d3f7e8763a104d5d35f4cd595a4c952366433", size = 1967479, upload-time = "2026-08-28T10:01:05.004Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fa/04/c81d4841331c2178b6fb09ae225425e110ed72d990c9fe556c4ec03d1013/pydantic_core-2.46.5-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8e24d8f05fa2d28513d94e877e9c75ad66175376209b3977f916e240e623193c", size = 2111034, upload-time = "2026-08-28T10:01:07.345Z" },
|
||||
]
|
||||
|
||||
[[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 = "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 = "pytest-asyncio"
|
||||
version = "1.4.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "pytest" },
|
||||
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/43/7c/d36d04db312ecf4298932ef77e6e4a9e8ad017906e24e34f0b0c361a2473/pytest_asyncio-1.4.0.tar.gz", hash = "sha256:c6c0d2259945122819f171a32ecea2c349ead889ee28176caaf492143424be42", size = 58514, upload-time = "2026-05-26T09:56:04.083Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/03/e2/08a497ef684b88559c9cc5f4ad53a37e7b99e727094a86d6ea32536d5d3c/pytest_asyncio-1.4.0-py3-none-any.whl", hash = "sha256:933ca923a23075a87fb7070c0ec272a6848489824d887c85c812670932835aa1", size = 16930, upload-time = "2026-05-26T09:56:02.576Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "researchx"
|
||||
version = "0.2.0"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "argon2-cffi" },
|
||||
{ name = "cryptography" },
|
||||
{ name = "fastapi" },
|
||||
{ name = "httpx" },
|
||||
{ name = "pydantic" },
|
||||
{ name = "sse-starlette" },
|
||||
{ name = "uvicorn" },
|
||||
]
|
||||
|
||||
[package.dev-dependencies]
|
||||
dev = [
|
||||
{ name = "httpx" },
|
||||
{ name = "mypy" },
|
||||
{ name = "pytest" },
|
||||
{ name = "pytest-asyncio" },
|
||||
{ name = "ruff" },
|
||||
]
|
||||
|
||||
[package.metadata]
|
||||
requires-dist = [
|
||||
{ name = "argon2-cffi", specifier = ">=23.1,<26" },
|
||||
{ name = "cryptography", specifier = ">=43" },
|
||||
{ name = "fastapi", specifier = ">=0.115,<0.116" },
|
||||
{ name = "httpx", specifier = ">=0.27,<1" },
|
||||
{ name = "pydantic", specifier = ">=2.8,<3" },
|
||||
{ name = "sse-starlette", specifier = ">=2.1,<3" },
|
||||
{ name = "uvicorn", specifier = ">=0.30,<1" },
|
||||
]
|
||||
|
||||
[package.metadata.requires-dev]
|
||||
dev = [
|
||||
{ name = "httpx", specifier = ">=0.28.1" },
|
||||
{ name = "mypy", specifier = ">=1.11" },
|
||||
{ name = "pytest", specifier = ">=8" },
|
||||
{ name = "pytest-asyncio", specifier = ">=0.23" },
|
||||
{ name = "ruff", specifier = ">=0.6" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ruff"
|
||||
version = "0.16.9"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/96/bf/c935ca98e73fe8ce65b87ef08a280c0c1e85295d569228c15e87d8fdfaf1/ruff-0.16.9.tar.gz", hash = "sha256:12b625c6cfba78d285d9f48eda5f053374f1e53cb10ef17342a383750db99161", size = 4948764, upload-time = "2026-09-24T20:37:49.416Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/0d/26/df51322b52ee1ada7eff2d071ea09d11d5c2d1dcc9f02594f5785c4d1635/ruff-0.16.9-py3-none-linux_armv6l.whl", hash = "sha256:95e6f022090368ab3b824c36276839c53b2adf1a3f4c09fefc33dfc400f6da96", size = 10082922, upload-time = "2026-09-24T20:37:13.045Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a5/27/7bf51f5a7aa375e9f339280a303aab44ca75dc1525f1cdc5991761685b0f/ruff-0.16.9-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:a5f27be168556594a86d2f415db0cf43f5291917849318f873c7e2791f7a8c67", size = 10236360, upload-time = "2026-09-24T20:37:16.049Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b6/63/09659283f92f02dff45809da194a70da2f688d87c55d8875c4fae3536072/ruff-0.16.9-py3-none-macosx_11_0_arm64.whl", hash = "sha256:1632eb1d6197f33bd00b1acbc5b71009e89a8895c158e2d2b03a834fac964ab6", size = 9892940, upload-time = "2026-09-24T20:37:17.957Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/24/58/98de1b72ec172f5f8f1731236fe21585b3998bf7dfe9fcc44ae9ba626012/ruff-0.16.9-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b3f951b14d865d5952c89d40a5ca07e87abe24fa5453299878411e127748fb1c", size = 10032114, upload-time = "2026-09-24T20:37:19.942Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c8/7d/f1e17c54ab59d4bad1dce8ee3e22a7a1d0ef4745240decacdcf3832b5bb2/ruff-0.16.9-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:447fc07e1573afff7cb02803462b12b6c8ece7cf10e2cd78565fa6d7a1c0bf8d", size = 9910227, upload-time = "2026-09-24T20:37:21.872Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/34/19/436f647a65075bbd3bab2668b3bdaa5120559b294694018cdcefabbbf30b/ruff-0.16.9-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8a3e039a6a40ed976c491722b60e0ae4a4aa1a86057f540ee7a37a5d19ae9120", size = 10547484, upload-time = "2026-09-24T20:37:24.229Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/03/59/38430a6bc2f6d8095447ac39625cf8b6e9344a47e6c26225c8ba1bff3ffb/ruff-0.16.9-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4684dded7db60aa57cb118fa158630f5feade4af5782903b6053484bdf9bd129", size = 11412367, upload-time = "2026-09-24T20:37:26.307Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c8/bd/bbb6d7fc7f208c8b8c50dd5dc8206e4cfdb1e7a8fb852606a3adf370c880/ruff-0.16.9-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d29c934357e45642fda2f34c0b1f4025b4a6c01e15e4bf0016879d60078a142c", size = 10869787, upload-time = "2026-09-24T20:37:28.35Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bc/b8/9c543074918061abbefc3bd139bee22de35abedb00dde0f2d27288838962/ruff-0.16.9-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a21713e629d3e5bdb2f5c2def1cc7f04f47fa8e1a7eb0571b4a28e1da64bc728", size = 10406494, upload-time = "2026-09-24T20:37:30.624Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/35/7a/5a8851bd146e7ccf8fd4b003f6c75c11f8fbdb0e60673b45097986b6bf41/ruff-0.16.9-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:7baa24ef5fc8e77aa93879e1d3f43754a01ae488e869f1ae30cf431afd4d2452", size = 10590083, upload-time = "2026-09-24T20:37:32.439Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/87/f0/4c3467188f23f806960b46fa76575a7cd0514c9ba90562650b71efc96980/ruff-0.16.9-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:a41aac6230aadfaa133bdfa1614488531ffa3e0837567ae04c0da2058a9c0f9e", size = 10119151, upload-time = "2026-09-24T20:37:34.581Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/15/34/5a4def5adea572ce6aea0bb64f21f928ee317b80e0db747d7979b01d7261/ruff-0.16.9-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:c2529fb5896d49115b0e9aa8f887490b34bbe76baf879ec2264ac59406869ce7", size = 9911544, upload-time = "2026-09-24T20:37:36.796Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/62/5d/d15ebea7499eef9373318c0ee6ca127832927c6529731f6d48e18dce7ca9/ruff-0.16.9-py3-none-musllinux_1_2_i686.whl", hash = "sha256:41e3870277694177429b56406d65dfbdb2c2802c52b715edaf6a0b829c69d4ee", size = 10269884, upload-time = "2026-09-24T20:37:38.857Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d1/56/c5d3cd119ded7a3c7aba0e961b69cb3df701c91662c98ad694467d060ce1/ruff-0.16.9-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:8adbe4e58af167f767d7b2ba5e83c42e878350796cf78c2f5e14ab9903a92588", size = 10749366, upload-time = "2026-09-24T20:37:41.042Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ac/fe/734ec7527029ac757ecf821f143c9f3fcf69149c21f53a044a899b430f5a/ruff-0.16.9-py3-none-win32.whl", hash = "sha256:0e1dbc2073624dee6618d41d0098690a7244654af746704b64759e12b6b6b385", size = 10152355, upload-time = "2026-09-24T20:37:43.025Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/14/21/26e4643629b3ebb44f0a06f9c9a53058d63d989415f63a9a3c28e2ee7f22/ruff-0.16.9-py3-none-win_amd64.whl", hash = "sha256:6bd40fec8cd4c8a3d4dd589bd8ad4e6320c13c29234159bfd959a40d529d597b", size = 10592965, upload-time = "2026-09-24T20:37:44.944Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/51/60/5fb1a39dbb5ae314d5f59bc7348a63c1d5c20f3cd83914c4b5cb0be31d2d/ruff-0.16.9-py3-none-win_arm64.whl", hash = "sha256:ed1a252039200f57a59eebc063b54beabea67bfbaaca0eeaa7f54b5fbcda2284", size = 10458649, upload-time = "2026-09-24T20:37:46.882Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sse-starlette"
|
||||
version = "2.4.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "anyio" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/07/3e/eae74d8d33e3262bae0a7e023bb43d8bdd27980aa3557333f4632611151f/sse_starlette-2.4.1.tar.gz", hash = "sha256:7c8a800a1ca343e9165fc06bbda45c78e4c6166320707ae30b416c42da070926", size = 18635, upload-time = "2025-07-06T09:41:33.631Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/e4/f1/6c7eaa8187ba789a6dd6d74430307478d2a91c23a5452ab339b6fbe15a08/sse_starlette-2.4.1-py3-none-any.whl", hash = "sha256:08b77ea898ab1a13a428b2b6f73cfe6d0e607a7b4e15b9bb23e4a37b087fd39a", size = 10824, upload-time = "2025-07-06T09:41:32.321Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "starlette"
|
||||
version = "0.46.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "anyio" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/ce/20/08dfcd9c983f6a6f4a1000d934b9e6d626cff8d2eeb77a89a68eef20a2b7/starlette-0.46.2.tar.gz", hash = "sha256:7f7361f34eed179294600af672f565727419830b54b7b084efe44bb82d2fccd5", size = 2580846, upload-time = "2025-04-13T13:56:17.942Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/8b/0c/9d30a4ebeb6db2b25a841afbb80f6ef9a854fc3b41be131d249a977b4959/starlette-0.46.2-py3-none-any.whl", hash = "sha256:595633ce89f8ffa71a015caed34a5b2dc1c0cdb3f0f1fbd1e69339cf2abeec35", size = 72037, upload-time = "2025-04-13T13:56:16.21Z" },
|
||||
]
|
||||
|
||||
[[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" },
|
||||
]
|
||||
@@ -66,6 +66,36 @@ hosts is wayfinding and accumulated research:
|
||||
| `pnpm typecheck` | `tsc --noEmit` (TypeScript strict) |
|
||||
| `pnpm test` | Vitest run (jsdom + Testing Library) |
|
||||
| `pnpm test:watch` | Vitest in watch mode |
|
||||
| `pnpm generate:api` | Regenerate `src/lib/api/generated/schema.d.ts` from the committed `openapi.json` snapshot (G-005 — never from a live server) |
|
||||
| `pnpm check:api-drift` | Schema-drift gate: regenerates from the snapshot and diffs (exit 1 on drift — G-005) |
|
||||
|
||||
## Auth + sessions (v0.3 P1)
|
||||
|
||||
The ApiClient boundary gained the session surface (C3-18 — the
|
||||
exhaustive list): `login` / `logout` / `getSession` plus the admin
|
||||
hooks `listUsers` / `createUser` / `deactivateUser`.
|
||||
|
||||
- **Mock mode** (the default): a standalone dev fixture — login accepts
|
||||
any non-empty credentials and swaps an in-memory session (the dev
|
||||
admin `dev00000`), admin hooks return small fixture arrays, and the
|
||||
app is never gated behind a login (REQ-31/REQ-54).
|
||||
- **Live mode** (`NEXT_PUBLIC_RESEARCHX_API_MODE=live`): real routes —
|
||||
`POST /api/auth/login`, `POST /api/auth/logout`, `GET /api/auth/me`
|
||||
(401 → null), `GET /api/admin/users`, `POST /api/admin/users`,
|
||||
`POST /api/admin/users/{user_id}/deactivate`. Every mutating request
|
||||
carries the `X-ResearchX-Request` CSRF header (C3-05) and every
|
||||
request sends `credentials: "include"` so the httpOnly session
|
||||
cookie rides along. A failed login rejects with a typed `AuthError`
|
||||
carrying the backend's uniform 401 copy.
|
||||
|
||||
The live-mode route guard (`SessionGate` in the root layout) bootstraps
|
||||
the session on load: an unauthenticated visitor is redirected to
|
||||
`/login` before any protected content renders; the login screen itself
|
||||
is never gated. Mock mode skips the check entirely — same components,
|
||||
zero mock-mode behavior change. Dev-live origins must share the
|
||||
`localhost` hostname on both sides (`http://localhost:8000` backend +
|
||||
`http://localhost:3000` frontend — the port is not part of the
|
||||
same-site computation).
|
||||
|
||||
## Architecture notes
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user