Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 10 additions & 6 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -29,12 +29,16 @@ jobs:
python-version: "3.12"

- name: Install pinned Rust toolchain
run: |
# Same pin as backend/Dockerfile: fast-mlsirm's PyO3/maturin
# core has no wheel, so pip install -e ".[backend]" compiles it.
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | \
sh -s -- -y --profile minimal --default-toolchain 1.97.1
echo "$HOME/.cargo/bin" >> "$GITHUB_PATH"
# Same pin as backend/Dockerfile: fast-mlsirm's PyO3/maturin
# core has no wheel, so pip install -e ".[backend]" compiles it.
# Pin the rust-toolchain action by commit SHA so CI does not pipe
# https://sh.rustup.rs into a shell (Strix CRITICAL on that pattern).
# SHA is dtolnay/rust-toolchain@master as of 2026-08-05; the action
# then installs toolchain 1.97.1. There is no rust-toolchain tag
# named 1.97.1.
uses: dtolnay/rust-toolchain@6c977a6ca4077a0ceb28ffbe03f59d46e9ac8772
with:
toolchain: 1.97.1

- name: Install package and test dependencies
run: python -m pip install -e ".[dev,backend]"
Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
__pycache__/
*.pyc
.pytest_cache/
.coverage
.venv/
*.egg-info/
.DS_Store
Expand Down
7 changes: 4 additions & 3 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,12 +48,13 @@ does it (`gh repo list ContextualWisdomLab`).

`NullEmbeddingClient`, `NullAdjudicationClient`,
`NullKeymanExtractionClient`, `NullEntityRelationshipClient`,
`NullPostSummaryClient`, `NullPostChatClient`, and
`NullCommitmentExtractionClient` (and any new channel client you add)
`NullPostSummaryClient`, `NullPostChatClient`,
`NullCommitmentExtractionClient`, and
`NullAnalysisRunOrchestrationClient` (and any new channel client you add)
must set `available = False` and make their channel dropped +
renormalized (`reconstruct.active_weights`), never silently return a
placeholder score, invented Keyman, guessed relationship, fabricated
summary/chat, or invented commitment. A missing signal and a
summary/chat, invented commitment, or invented analysis-run completion. A missing signal and a
confidently-negative signal are different things. Keyman extraction,
entity-relationship classification, post summary, in-popup chat, and
commitment derivation go through contextual-orchestrator the same way
Expand Down
13 changes: 11 additions & 2 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,15 @@ identities and content) and `migrations/0001_initial_schema.sql` for the
(skipped without a reachable PostgreSQL server, same pattern as the
real-provider LLM tests).

Milestone 2.1 adds an additive analysis-run registry schema in
`migrations/0013_analysis_run_registry.sql` (ADR 0014): immutable source
snapshots, aggregate counts, account-scoped runs, one product scope, and
append-only status events with a derived current-status view. A raw insert
can store a run with no scope, no counts, and no pending event until a
later write API creates those rows atomically. Beginner ERD:
[`docs/analysis-run-registry.md`](docs/analysis-run-registry.md). This
slice has no public CRUD API.

### Local infrastructure (Docker Compose)

`docker-compose.yml` runs PostgreSQL, Valkey, and a real Keycloak OIDC
Expand All @@ -163,8 +172,8 @@ makes them reproducible in CI. Valkey is the Phase 2+ event queue (not a
traditional MQ) for asynchronous work like Keyman/Knowledge-Graph
recomputation once posts change. Postgres's app database is auto-migrated
on first boot from the same `migrations/0001_initial_schema.sql` file
`tests/test_schema.py` applies -- one schema file, no drift between what's
tested and what ships.
`tests/test_schema.py` applies, plus later `0002`–`0012` upgrades -- one
schema chain, no drift between what's tested and what ships.

### Backend (`backend/`)

Expand Down
27 changes: 27 additions & 0 deletions CHANGELOG.d/0.76.0-analysis-run-registry.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
# 0.76.0 — Normalized analysis-run registry

## Added

- Migration `0013_analysis_run_registry.sql` adds the Milestone 2.1
registry schema: immutable source snapshots, non-negative aggregate
counts, account-scoped analysis runs, one authorization scope per run,
append-only status events, and a derived current-status view. A raw
insert can store a run with no scope, no counts, and no pending event
until a later write API creates those rows atomically.
- Fail-closed TEPP helpers bind `AnalysisRunRequest` to `snapshot_id` and
`knowledge_cutoff` without forking TEPP arithmetic.
- New contextual-orchestrator helpers request `mode="auto"` only.

## Fixed

- JWT verification requires a non-empty `kid` that matches a JWKS key.
Tokens that omit `kid` no longer fall back to the first published key.
- CI installs rustc 1.97.1 through a SHA-pinned `dtolnay/rust-toolchain`
action instead of piping `https://sh.rustup.rs` into a shell. The
backend image fetches rustup-init 1.28.2 from the versioned archive
and checks its SHA-256 before running it.
- Product images declare a real `HEALTHCHECK` against an existing probe
(`GET /healthz` on backend and frontend, `pg_isready` on Postgres,
SearXNG `/healthz`). Locked floors are now `pyjwt>=2.13.0` and
`fastapi>=0.141.1`.

31 changes: 31 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,37 @@ All notable changes to this project are documented here. Format follows
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/); versioning follows
[Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [0.76.0] - 2026-08-17

### Added

- Additive analysis-run registry schema
(`migrations/0013_analysis_run_registry.sql`, ADR 0014). The tables can
hold an immutable source snapshot, aggregate reconciliation counts, an
authenticated Demo Corp requester, run-owned knowledge cutoff, product
scope, and append-only legal status events. Current status is a view.
This slice is schema only: a raw insert can store a run with no scope,
no counts, and no pending event. Treat a run as recorded only after a
later write API stores snapshot, counts, run, scope, and pending in one
transaction (ADR 0014 follow-up 1). There is no public CRUD API. TEPP
stays fail-closed unless an HTTPS `POST /v1/analysis-runs` or
in-process `tepp_api` is injected. New orchestrator helpers use
`mode="auto"` only.

### Fixed

- JWT verification now requires a non-empty `kid` and a matching JWKS
key. A token that omits `kid` no longer falls back to the first key.
- CI installs rustc 1.97.1 through a SHA-pinned `dtolnay/rust-toolchain`
action instead of piping `https://sh.rustup.rs` into a shell. The
backend image fetches rustup-init 1.28.2 from the versioned archive
and checks its SHA-256 before running it.
- Product images declare a real `HEALTHCHECK` against an existing probe
(`GET /healthz` on backend and frontend, `pg_isready` on Postgres,
SearXNG `/healthz`). The frontend probe is a static nginx 200, not
the SPA fallback. Locked floors are now `pyjwt>=2.13.0` and
`fastapi>=0.141.1`; `react-oidc-context` was already `^3.3.1`.

## [0.75.0] - 2026-08-17

### Added
Expand Down
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -129,7 +129,8 @@ Postgres/Redis/local server on those. Override via `.env` (copy
Postgres's `POSTGRES_DB` (the "app" database) is migrated automatically on
first boot -- `docker/postgres-init/Dockerfile` bakes in the exact same
`migrations/0001_initial_schema.sql` file `tests/test_schema.py` applies,
no re-typed copy.
no re-typed copy, then the later `0002`–`0012` upgrade files including
the analysis-run registry.

`backend/` is a FastAPI app talking directly to that database (`asyncpg`,
no ORM, no file DB) and to Keycloak's live JWKS for OIDC verification:
Expand Down
19 changes: 15 additions & 4 deletions backend/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,18 @@ RUN apt-get update && apt-get install -y --no-install-recommends git build-essen
&& useradd --uid 1000 --gid appuser --create-home appuser

# Pinned, non-interactive rustup install (minimal profile: no docs/clippy,
# just rustc+cargo) -- same "pinned, reproducible install" discipline this
# project already applies to rankweave/fast-mlsirm's own commit pins.
RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | \
sh -s -- -y --profile minimal --default-toolchain 1.97.1
# just rustc+cargo). Fetch rustup-init from the versioned archive and
# verify SHA-256 so the image does not pipe https://sh.rustup.rs into a
# shell. Checksum is rustup 1.28.2 for x86_64-unknown-linux-gnu.
ARG RUSTUP_VERSION=1.28.2
ARG RUSTUP_SHA256=20a06e644b0d9bd2fbdbfd52d42540bdde820ea7df86e92e533c073da0cdd43c
RUN curl --proto '=https' --tlsv1.2 -sSfL \
"https://static.rust-lang.org/rustup/archive/${RUSTUP_VERSION}/x86_64-unknown-linux-gnu/rustup-init" \
-o /tmp/rustup-init \
&& echo "${RUSTUP_SHA256} /tmp/rustup-init" | sha256sum -c - \
&& chmod +x /tmp/rustup-init \
&& /tmp/rustup-init -y --profile minimal --default-toolchain 1.97.1 \
&& rm /tmp/rustup-init
ENV PATH="/root/.cargo/bin:${PATH}"

COPY pyproject.toml ./
Expand All @@ -32,4 +40,7 @@ RUN pip install --no-cache-dir ".[backend]" \

USER appuser
EXPOSE 8000
# Process liveness only -- same contract as GET /healthz (no Postgres).
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \
CMD curl -fsS http://127.0.0.1:8000/healthz || exit 1
CMD ["uvicorn", "backend.app.main:app", "--host", "0.0.0.0", "--port", "8000"]
7 changes: 6 additions & 1 deletion backend/app/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,8 +47,13 @@ def _signing_key_from_jwks(jwks: dict, token: str):
"""Pick the JWKS RSA key that matches the JWT kid, without urllib."""
header = jwt.get_unverified_header(token)
kid = header.get("kid")
if not isinstance(kid, str) or not kid.strip():
raise HTTPException(
status.HTTP_401_UNAUTHORIZED,
"JWT header is missing kid",
)
for key in jwks.get("keys", []):
if kid is None or key.get("kid") == kid:
if key.get("kid") == kid:
return RSAAlgorithm.from_jwk(json.dumps(key))
raise HTTPException(status.HTTP_401_UNAUTHORIZED, f"no JWKS key matched kid={kid!r}")

Expand Down
62 changes: 62 additions & 0 deletions backend/tests/test_auth_jwks.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
"""JWKS key selection must require a matching JWT kid."""

from __future__ import annotations

import json

import jwt
import pytest
from cryptography.hazmat.primitives.asymmetric import rsa
from fastapi import HTTPException
from jwt.algorithms import RSAAlgorithm

from backend.app.auth import _signing_key_from_jwks


def _rsa_jwk_and_private_key(*, kid: str) -> tuple[dict, object]:
"""Return one JWKS RSA public key and the matching private key."""

private_key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
public_jwk = json.loads(RSAAlgorithm.to_jwk(private_key.public_key()))
public_jwk["kid"] = kid
public_jwk["use"] = "sig"
public_jwk["alg"] = "RS256"
return public_jwk, private_key


def test_signing_key_requires_kid_and_rejects_missing_or_unknown_kid() -> None:
"""A token without kid must not fall back to the first JWKS key."""

first_jwk, first_private = _rsa_jwk_and_private_key(kid="first-key")
second_jwk, _second_private = _rsa_jwk_and_private_key(kid="second-key")
jwks = {"keys": [first_jwk, second_jwk]}

matched = jwt.encode(
{"sub": "demo-analyst"},
first_private,
algorithm="RS256",
headers={"kid": "first-key"},
)
assert _signing_key_from_jwks(jwks, matched) is not None

missing_kid = jwt.encode(
{"sub": "demo-analyst"},
first_private,
algorithm="RS256",
headers={},
)
with pytest.raises(HTTPException) as missing:
_signing_key_from_jwks(jwks, missing_kid)
assert missing.value.status_code == 401
assert "missing kid" in missing.value.detail

unknown_kid = jwt.encode(
{"sub": "demo-analyst"},
first_private,
algorithm="RS256",
headers={"kid": "unknown-key"},
)
with pytest.raises(HTTPException) as unknown:
_signing_key_from_jwks(jwks, unknown_kid)
assert unknown.value.status_code == 401
assert "unknown-key" in unknown.value.detail
17 changes: 15 additions & 2 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,10 @@ services:
# product schema migration ship inside the image itself -- portable
# across hosts/CI runners that don't share a filesystem with the
# Docker daemon. Context is the repo root so the Dockerfile can COPY
# migrations/0001_initial_schema.sql (single source of truth --
# tests/test_schema.py applies this exact same file).
# migrations/0001_initial_schema.sql plus later 0002-0013 upgrades
# (single source of truth -- tests/test_schema.py applies 0001;
# the leftover-pair contract applies through 0012; the registry
# contract applies through 0013).
build:
context: .
dockerfile: docker/postgres-init/Dockerfile
Expand Down Expand Up @@ -107,6 +109,12 @@ services:
SEARXNG_BASE_URL: http://searxng:8080
ports:
- "${BACKEND_PORT:-18420}:8000"
healthcheck:
test: ["CMD", "curl", "-fsS", "http://127.0.0.1:8000/healthz"]
interval: 30s
timeout: 5s
retries: 3
start_period: 20s
depends_on:
postgres:
condition: service_healthy
Expand All @@ -126,6 +134,11 @@ services:
VITE_BACKEND_BASE_URL: http://localhost:${BACKEND_PORT:-18420}
ports:
- "${FRONTEND_PORT:-15173}:8080"
healthcheck:
test: ["CMD", "wget", "-qO-", "http://127.0.0.1:8080/healthz"]
interval: 30s
timeout: 5s
retries: 3
depends_on:
- backend

Expand Down
4 changes: 4 additions & 0 deletions docker/postgres-init/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,10 @@ COPY migrations/0009_shared_metric_bank.sql /docker-entrypoint-initdb.d/10-share
COPY migrations/0010_report_item_information.sql /docker-entrypoint-initdb.d/11-report-item-information.sql
COPY migrations/0011_post_chat_result.sql /docker-entrypoint-initdb.d/12-post-chat-result.sql
COPY migrations/0012_report_leftover_pair.sql /docker-entrypoint-initdb.d/13-report-leftover-pair.sql
COPY migrations/0013_analysis_run_registry.sql /docker-entrypoint-initdb.d/14-analysis-run-registry.sql
# Official image already drops to this account at runtime; declare it so
# the Dockerfile itself satisfies DS-0002 (explicit non-root USER).
USER postgres
# Local socket readiness. Compose still passes user/db on its own probe.
HEALTHCHECK --interval=5s --timeout=5s --retries=10 \
CMD pg_isready
3 changes: 3 additions & 0 deletions docker/searxng/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,6 @@ COPY settings.yml /etc/searxng/settings.yml
# Dockerfile itself satisfies DS-0002 (explicit non-root USER), matching
# docker/keycloak/Dockerfile's own USER declaration.
USER searxng
# Same readiness probe as docker-compose.yml's searxng healthcheck.
HEALTHCHECK --interval=5s --timeout=5s --retries=10 \
CMD wget -qO- http://127.0.0.1:8080/healthz >/dev/null || exit 1
Loading
Loading