diff --git a/.claude/launch.json b/.claude/launch.json
new file mode 100644
index 000000000..1bd95af54
--- /dev/null
+++ b/.claude/launch.json
@@ -0,0 +1,32 @@
+{
+ "version": "0.0.1",
+ "configurations": [
+ {
+ "name": "launchpad-production",
+ "runtimeExecutable": "bash",
+ "runtimeArgs": [
+ "-c",
+ "AIGNOSTICS_API_ROOT=https://platform.aignostics.com uv run aignostics system serve --host 127.0.0.1 --port 8801"
+ ],
+ "port": 8801
+ },
+ {
+ "name": "launchpad-staging",
+ "runtimeExecutable": "bash",
+ "runtimeArgs": [
+ "-c",
+ "AIGNOSTICS_API_ROOT=https://platform-staging.aignostics.com uv run aignostics system serve --host 127.0.0.1 --port 8802"
+ ],
+ "port": 8802
+ },
+ {
+ "name": "launchpad-dev",
+ "runtimeExecutable": "bash",
+ "runtimeArgs": [
+ "-c",
+ "AIGNOSTICS_API_ROOT=https://platform-dev.aignostics.ai uv run aignostics system serve --host 127.0.0.1 --port 8803"
+ ],
+ "port": 8803
+ }
+ ]
+}
diff --git a/.claude/scheduled_tasks.lock b/.claude/scheduled_tasks.lock
new file mode 100644
index 000000000..f12613462
--- /dev/null
+++ b/.claude/scheduled_tasks.lock
@@ -0,0 +1 @@
+{"sessionId":"141e42d1-2313-4aa6-8525-e62a6191f6d1","pid":79441,"procStart":"Sun Apr 26 08:58:29 2026","acquiredAt":1777200880667}
diff --git a/.claude/settings.json b/.claude/settings.json
new file mode 100644
index 000000000..d6e1f23b7
--- /dev/null
+++ b/.claude/settings.json
@@ -0,0 +1,39 @@
+{
+ "permissions": {
+ "allow": [
+ "mcp__claude_ai_Atlassian__getConfluencePage",
+ "mcp__claude_ai_Atlassian__getVisibleJiraProjects",
+ "mcp__claude_ai_Atlassian__getAccessibleAtlassianResources",
+ "mcp__claude_ai_Atlassian__getCompassComponent",
+ "mcp__claude_ai_Atlassian__getJiraIssue",
+ "mcp__claude_ai_Atlassian__searchJiraIssuesUsingJql",
+ "mcp__claude_ai_Atlassian__search",
+ "mcp__claude_ai_Atlassian__getConfluenceSpaces",
+ "mcp__claude_ai_Atlassian__getPagesInConfluenceSpace",
+ "mcp__claude-in-chrome__read_page",
+ "mcp__claude-in-chrome__get_page_text",
+ "mcp__claude-in-chrome__tabs_context_mcp",
+ "Bash(make lint)",
+ "Bash(make lint_fix)",
+ "Bash(make test_unit)",
+ "Bash(make test_integration)",
+ "Bash(make audit)",
+ "Bash(gh label list *)",
+ "Bash(gh search *)"
+ ]
+ },
+ "enableAllProjectMcpServers": true,
+ "enabledMcpjsonServers": [],
+ "extraKnownMarketplaces": {
+ "aignostics-claude-plugins": {
+ "source": {
+ "source": "github",
+ "repo": "aignostics/claude-plugins"
+ },
+ "autoUpdate": true
+ }
+ },
+ "enabledPlugins": {
+ "qms@aignostics-claude-plugins": true
+ }
+}
\ No newline at end of file
diff --git a/.github/CLAUDE.md b/.github/CLAUDE.md
index 5f15f6cca..2b5d19bc2 100644
--- a/.github/CLAUDE.md
+++ b/.github/CLAUDE.md
@@ -90,6 +90,7 @@ The Aignostics Python SDK uses a **sophisticated multi-stage CI/CD pipeline** bu
| Workflow | Purpose | Duration | Key Outputs |
|----------|---------|----------|-------------|
| **_lint.yml** | Code quality (ruff, pyright, mypy) | ~5 min | Formatted code, type safety |
+| **_docs.yml** | Documentation build (Sphinx) | ~3 min | HTML docs, validation |
| **_audit.yml** | Security + license compliance | ~3 min | SBOM (CycloneDX, SPDX), vulnerabilities, licenses |
| **_test.yml** | Multi-stage test execution | ~15 min | Coverage reports, JUnit XML |
| **_codeql.yml** | Security vulnerability scanning | ~10 min | CodeQL SARIF results |
@@ -115,10 +116,12 @@ The SDK has **7 test categories** with different execution strategies.
def test_something():
pass
+
# ❌ INCORRECT - No category marker, will NOT run in CI
def test_something_else():
pass
+
# ✅ CORRECT - Multiple markers including category
@pytest.mark.e2e
@pytest.mark.long_running
@@ -1099,6 +1102,7 @@ make dist_native
| `audit-scheduled.yml` | Entry | Security audit | ~5 min |
| `codeql-scheduled.yml` | Entry | CodeQL scan | ~10 min |
| `_lint.yml` | Reusable | Code quality checks | ~5 min |
+| `_docs.yml` | Reusable | Documentation build | ~3 min |
| `_audit.yml` | Reusable | Security & license | ~3 min |
| `_test.yml` | Reusable | Test execution | ~15 min |
| `_codeql.yml` | Reusable | Security scanning | ~10 min |
diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md
index 61eabdb41..3e38c276a 100644
--- a/.github/copilot-instructions.md
+++ b/.github/copilot-instructions.md
@@ -96,13 +96,14 @@ make lint # Ruff formatting + MyPy type checking
```python
from aignostics.utils import BaseService, Health
+
class Service(BaseService):
def __init__(self):
super().__init__(SettingsClass) # Optional settings
-
+
def health(self) -> Health:
return Health(status=Health.Code.UP)
-
+
def info(self, mask_secrets: bool = True) -> dict:
return {"version": "1.0.0"}
```
@@ -114,6 +115,7 @@ from ._service import Service
cli = typer.Typer(name="module", help="Module description")
+
@cli.command("action")
def action_command(param: str):
"""Command description."""
@@ -126,6 +128,7 @@ def action_command(param: str):
```python
from nicegui import ui
+
def create_page():
ui.label("Module Interface")
# Components auto-register with GUI launcher
@@ -177,9 +180,10 @@ def create_page():
```python
from pydantic_settings import BaseSettings
+
class Settings(BaseSettings):
api_root: str = "https://platform.aignostics.com"
-
+
class Config:
env_prefix = "AIGNOSTICS_"
```
@@ -200,7 +204,7 @@ client = platform.Client()
run = client.runs.create(
application_id="heta",
application_version="1.0.0", # version number without 'v' prefix, omit for latest
- items=[platform.InputItem(...)]
+ items=[platform.InputItem(...)],
)
# 3. Monitor & download
diff --git a/.github/labels.yml b/.github/labels.yml
index f5c45cd28..b316634cd 100644
--- a/.github/labels.yml
+++ b/.github/labels.yml
@@ -117,6 +117,14 @@
description: Trigger Claude Code automation
color: "b41d8f"
+- name: claude:review:passed
+ description: Automated Claude PR review found no blocking issues on the current head commit
+ color: "0e8a16"
+
+- name: claude:review:failed
+ description: Automated Claude PR review found blocking issues on the current head commit
+ color: "b60205"
+
- name: copilot
description: GitHub Copilot related
color: "e6dac6"
@@ -133,3 +141,76 @@
- name: documentation-drift
description: Documentation out of sync with code
color: "ff6b6b"
+
+# SOP Labels — governance trail on every PR (one mandatory)
+- name: sop:pr-sop-01
+ description: PR-SOP-01 Problem Resolution (bug / anomaly fix)
+ color: "5319e7"
+
+- name: sop:cc-sop-01
+ description: CC-SOP-01 Change Control (feature / planned change)
+ color: "1d76db"
+
+# Type Labels — conventional-commits taxonomy (one per PR)
+# Extends the legacy `bug` / `documentation` / `enhancement` labels with
+# the rest of the conventional-commit vocabulary. Legacy labels remain
+# for backward-compatibility with issue templates and external tooling;
+# the `type:*` namespace is the source of truth for PR-level filtering.
+- name: type:feature
+ description: New functionality (conventional feat)
+ color: "a2eeef"
+
+- name: type:fix
+ description: Bug fix (conventional fix)
+ color: "d73a4a"
+
+- name: type:chore
+ description: Tooling, maintenance, routine task (conventional chore)
+ color: "c5def5"
+
+- name: type:refactor
+ description: Refactor without behaviour change
+ color: "fbca04"
+
+- name: type:docs
+ description: Documentation-only change
+ color: "0075ca"
+
+- name: type:test
+ description: Test-only change
+ color: "006b75"
+
+- name: type:perf
+ description: Performance improvement
+ color: "4b0082"
+
+- name: type:build
+ description: Build / packaging change
+ color: "5319e7"
+
+- name: type:ci
+ description: CI/CD change
+ color: "000000"
+
+# Security Labels — orthogonal axis (0–2 per PR)
+- name: security
+ description: Addresses a security advisory, CVE, or hardens security posture
+ color: "b60205"
+
+- name: security:supply-chain
+ description: Supply-chain (dependency) vulnerability remediation
+ color: "d93f0b"
+
+# Scope Labels — who the change affects (0–1 per PR)
+- name: scope:sdk-consumers
+ description: Affects downstream SDK consumers (uvx aignostics / uv add aignostics)
+ color: "0e8a16"
+
+- name: scope:dev-only
+ description: Affects only our dev/CI env; consumers unaffected
+ color: "bfdadc"
+
+# Automation Labels
+- name: auto-merge
+ description: Eligible for auto-merge once CI is green
+ color: "0e8a16"
diff --git a/.github/workflows/_audit.yml b/.github/workflows/_audit.yml
index 69feb9749..0cf8e1459 100644
--- a/.github/workflows/_audit.yml
+++ b/.github/workflows/_audit.yml
@@ -37,7 +37,7 @@ jobs:
run: make audit
- name: Upload audit results
- uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
if: ${{ always() && (env.GITHUB_WORKFLOW_RUNTIME != 'ACT') }}
with:
name: audit-results
diff --git a/.github/workflows/_build-native-only.yml b/.github/workflows/_build-native-only.yml
index 8ea2a4944..e46e239fc 100644
--- a/.github/workflows/_build-native-only.yml
+++ b/.github/workflows/_build-native-only.yml
@@ -61,7 +61,7 @@ jobs:
run: make dist_native
- name: Upload dist_native artifacts
- uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
if: ${{ always() && (env.GITHUB_WORKFLOW_RUNTIME != 'ACT') }}
with:
name: dist_native-${{ matrix.runner }}
diff --git a/.github/workflows/_claude-code.yml b/.github/workflows/_claude-code.yml
index 4828f1e73..322a4da1c 100644
--- a/.github/workflows/_claude-code.yml
+++ b/.github/workflows/_claude-code.yml
@@ -77,7 +77,7 @@ jobs:
- name: Run Claude Code (Interactive Mode)
if: inputs.mode == 'interactive'
- uses: anthropics/claude-code-action@6e2bd52842c65e914eba5c8badd17560bd26b5de # v1.0.89
+ uses: anthropics/claude-code-action@b47fd721da662d48c5680e154ad16a73ed74d2e0 # v1.0.93
with:
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
track_progress: ${{ inputs.track_progress }}
@@ -92,7 +92,7 @@ jobs:
- name: Run Claude Code (Automation Mode)
if: inputs.mode == 'automation'
- uses: anthropics/claude-code-action@6e2bd52842c65e914eba5c8badd17560bd26b5de # v1.0.89
+ uses: anthropics/claude-code-action@b47fd721da662d48c5680e154ad16a73ed74d2e0 # v1.0.93
with:
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
track_progress: ${{ inputs.track_progress }}
diff --git a/.github/workflows/_docker-publish.yml b/.github/workflows/_docker-publish.yml
index 732ff8c9b..83a7694da 100644
--- a/.github/workflows/_docker-publish.yml
+++ b/.github/workflows/_docker-publish.yml
@@ -81,7 +81,7 @@ jobs:
- name: "(all target): Build and push Docker image"
id: build-and-push-all
- uses: docker/build-push-action@d08e5c354a6adb9ed34480a06d141179aa583294 # v7.0.0
+ uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
with:
context: .
file: ./Dockerfile
@@ -93,7 +93,7 @@ jobs:
- name: "(slim target): Build and push Docker image"
id: build-and-push-slim
- uses: docker/build-push-action@d08e5c354a6adb9ed34480a06d141179aa583294 # v7.0.0
+ uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
with:
context: .
file: ./Dockerfile
diff --git a/.github/workflows/_docs.yml b/.github/workflows/_docs.yml
new file mode 100644
index 000000000..01f19594f
--- /dev/null
+++ b/.github/workflows/_docs.yml
@@ -0,0 +1,37 @@
+name: "> Docs"
+
+on:
+ workflow_call:
+ # No secrets needed
+
+jobs:
+ docs:
+ runs-on: ubuntu-latest
+ permissions:
+ contents: read
+ id-token: write
+ packages: read
+ steps:
+ - name: Checkout
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ with:
+ fetch-depth: 0
+
+ - name: Install uv
+ uses: astral-sh/setup-uv@cec208311dfd045dd5311c1add060b2062131d57 # v8.0.0
+ with:
+ version-file: "pyproject.toml"
+ enable-cache: true
+ cache-dependency-glob: uv.lock
+
+ - name: Install dev tools
+ shell: bash
+ run: .github/workflows/_install_dev_tools.bash
+
+ - name: Install Python, venv and dependencies
+ shell: bash
+ run: uv sync --all-extras --frozen --link-mode=copy
+
+ - name: Docs
+ shell: bash
+ run: make docs
diff --git a/.github/workflows/_package-publish.yml b/.github/workflows/_package-publish.yml
index 16ba3e945..f2e78ebe4 100644
--- a/.github/workflows/_package-publish.yml
+++ b/.github/workflows/_package-publish.yml
@@ -83,7 +83,7 @@ jobs:
run: make dist_native
- name: Upload dist_native artifacts
- uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
if: ${{ always() && (env.GITHUB_WORKFLOW_RUNTIME != 'ACT') }}
with:
name: dist_native-${{ matrix.runner }}
diff --git a/.github/workflows/_scheduled-audit.yml b/.github/workflows/_scheduled-audit.yml
index e18c402fa..8309325e5 100644
--- a/.github/workflows/_scheduled-audit.yml
+++ b/.github/workflows/_scheduled-audit.yml
@@ -87,7 +87,7 @@ jobs:
exit $EXIT_CODE
- name: Upload test results
- uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
if: ${{ always() && (env.GITHUB_WORKFLOW_RUNTIME != 'ACT') }}
with:
name: test-results-scheduled
diff --git a/.github/workflows/_scheduled-test-daily.yml b/.github/workflows/_scheduled-test-daily.yml
index 05bc4853b..572dd4081 100644
--- a/.github/workflows/_scheduled-test-daily.yml
+++ b/.github/workflows/_scheduled-test-daily.yml
@@ -182,7 +182,7 @@ jobs:
summary-title: All very long running e2e tests passed
- name: Upload test artifacts for inspection
- uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
if: ${{ always() && (env.GITHUB_WORKFLOW_RUNTIME != 'ACT') }}
with:
name: test-results-ubuntu-latest
diff --git a/.github/workflows/_scheduled-test-hourly.yml b/.github/workflows/_scheduled-test-hourly.yml
index 738554cfc..939366198 100644
--- a/.github/workflows/_scheduled-test-hourly.yml
+++ b/.github/workflows/_scheduled-test-hourly.yml
@@ -170,7 +170,7 @@ jobs:
exit $EXIT_CODE
- name: Upload test results
- uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
if: ${{ always() && (env.GITHUB_WORKFLOW_RUNTIME != 'ACT') }}
with:
name: test-results-scheduled
diff --git a/.github/workflows/_test.yml b/.github/workflows/_test.yml
index dbb0708fb..85c4b021b 100644
--- a/.github/workflows/_test.yml
+++ b/.github/workflows/_test.yml
@@ -228,7 +228,7 @@ jobs:
commit-message: ${{ inputs.commit_message }}
- name: Upload test artifacts for inspection
- uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
if: ${{ always() && (env.GITHUB_WORKFLOW_RUNTIME != 'ACT') }}
with:
name: test-results-${{ matrix.runner }}
diff --git a/.github/workflows/ci-cd.yml b/.github/workflows/ci-cd.yml
index da162873f..6919fedc6 100644
--- a/.github/workflows/ci-cd.yml
+++ b/.github/workflows/ci-cd.yml
@@ -93,6 +93,19 @@ jobs:
id-token: write
packages: read
+ docs:
+ needs: [get-commit-message]
+ if: |
+ (!contains(needs.get-commit-message.outputs.commit_message, 'skip:ci')) &&
+ (!contains(needs.get-commit-message.outputs.commit_message, 'build:native:only')) &&
+ (!contains(github.event.pull_request.labels.*.name, 'skip:ci')) &&
+ (!contains(github.event.pull_request.labels.*.name, 'build:native:only'))
+ uses: ./.github/workflows/_docs.yml
+ permissions:
+ contents: read
+ id-token: write
+ packages: read
+
audit:
needs: [get-commit-message]
if: |
@@ -172,7 +185,7 @@ jobs:
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
ketryx_report_and_check:
- needs: [get-commit-message, lint, audit, test, codeql, sonarcloud]
+ needs: [get-commit-message, lint, audit, test, codeql, sonarcloud, docs]
if: |
github.actor != 'dependabot[bot]' &&
(!contains(needs.get-commit-message.outputs.commit_message, 'skip:ci')) &&
diff --git a/.github/workflows/claude-code-automation-pr-review.yml b/.github/workflows/claude-code-automation-pr-review.yml
index 55d5f5929..11dc0e8a1 100644
--- a/.github/workflows/claude-code-automation-pr-review.yml
+++ b/.github/workflows/claude-code-automation-pr-review.yml
@@ -12,7 +12,8 @@ concurrency:
jobs:
claude-review:
if: |
- contains(github.event.pull_request.labels.*.name, 'claude') ||
+ (github.event.action == 'labeled' && github.event.label.name == 'claude') ||
+ (github.event.action != 'labeled' && contains(github.event.pull_request.labels.*.name, 'claude')) ||
github.event.action == 'ready_for_review'
uses: ./.github/workflows/_claude-code.yml
with:
@@ -214,6 +215,49 @@ jobs:
Use `gh pr comment` with your Bash tool to leave your comprehensive review as a comment on the PR.
+ ## Machine-Readable Verdict (MANDATORY)
+
+ After posting your review comment, you MUST emit a single-label verdict on the PR. This label is consumed by branch-protection rules to gate auto-merge — it is the only deterministic signal of your review outcome.
+
+ **Verdict criteria** (all must hold for PASS):
+
+ - No blocking findings under "CRITICAL CHECKS" — i.e. no missing test markers, no coverage drop below 85%, no `make lint` failures, no conventional-commit violations.
+ - No blocking architecture or security violations under "Repository-Specific Review Areas".
+ - Suggestions / nice-to-haves do NOT block the verdict.
+
+ If any blocking finding remains: verdict is **FAIL**.
+ Otherwise: verdict is **PASS**.
+
+ **Apply the label** (the two labels are mutually exclusive — always remove the opposite one):
+
+ ```bash
+ # PASS:
+ gh pr edit ${{ github.event.pull_request.number }} \
+ --add-label "claude:review:passed" \
+ --remove-label "claude:review:failed"
+
+ # FAIL:
+ gh pr edit ${{ github.event.pull_request.number }} \
+ --add-label "claude:review:failed" \
+ --remove-label "claude:review:passed"
+ ```
+
+ Note: `--remove-label` is a no-op if the label is not present, so it is safe to always include it.
+
+ Also include the verdict as the final line of your sticky review comment, formatted exactly as:
+
+ ```
+ **Verdict**: ✅ claude:review:passed
+ ```
+
+ or
+
+ ```
+ **Verdict**: ❌ claude:review:failed
+ ```
+
+ This makes the verdict visible to humans without scrolling through all findings.
+
---
**Remember**: This is medical device software. Insist on highest standards. Be thorough, actionable, and kind.
diff --git a/.github/workflows/merge-release.yml b/.github/workflows/merge-release.yml
index b6443909e..6e571ef38 100644
--- a/.github/workflows/merge-release.yml
+++ b/.github/workflows/merge-release.yml
@@ -21,7 +21,7 @@ jobs:
steps:
- name: Generate GitHub App token
id: app-token
- uses: actions/create-github-app-token@f8d387b68d61c58ab83c6c016672934102569859 # v3.0.0
+ uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3.1.1
with:
app-id: ${{ secrets.RELEASE_BOT_APP_ID }}
private-key: ${{ secrets.RELEASE_BOT_PRIVATE_KEY }}
diff --git a/.github/workflows/prepare-release.yml b/.github/workflows/prepare-release.yml
index 737057847..aae309ccc 100644
--- a/.github/workflows/prepare-release.yml
+++ b/.github/workflows/prepare-release.yml
@@ -21,7 +21,7 @@ jobs:
steps:
- name: Generate GitHub App token
id: app-token
- uses: actions/create-github-app-token@f8d387b68d61c58ab83c6c016672934102569859 # v3.0.0
+ uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3.1.1
with:
app-id: ${{ secrets.RELEASE_BOT_APP_ID }}
private-key: ${{ secrets.RELEASE_BOT_PRIVATE_KEY }}
diff --git a/.github/workflows/publish-release.yml b/.github/workflows/publish-release.yml
index 8d79ef71a..8c91a9c61 100644
--- a/.github/workflows/publish-release.yml
+++ b/.github/workflows/publish-release.yml
@@ -21,7 +21,7 @@ jobs:
steps:
- name: Generate GitHub App token
id: app-token
- uses: actions/create-github-app-token@f8d387b68d61c58ab83c6c016672934102569859 # v3.0.0
+ uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3.1.1
with:
app-id: ${{ secrets.RELEASE_BOT_APP_ID }}
private-key: ${{ secrets.RELEASE_BOT_PRIVATE_KEY }}
diff --git a/.gitignore b/.gitignore
index dbf4155af..d9b9b1630 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,5 +1,9 @@
# .gitignore of project Aignostics Python SDK
+# Claude Code agent worktrees — created locally per session, never committed.
+# Note: .claude/settings.json is intentionally tracked; only worktrees are ignored.
+.claude/worktrees/
+
# Environment
.env
.env.*
@@ -86,13 +90,12 @@ CLAUDE.local.md
# Scalene
profile.json
profile.html
+scalene-profile.json
+scalene-profile.html
# Nicegui
.nicegui
-# MCP
-.mcp.json
-
# Application specific
data/**
!data/.keep
diff --git a/.mcp.json b/.mcp.json
new file mode 100644
index 000000000..259a95910
--- /dev/null
+++ b/.mcp.json
@@ -0,0 +1,8 @@
+{
+ "mcpServers": {
+ "playwright": {
+ "command": "npx",
+ "args": ["@playwright/mcp@latest"]
+ }
+ }
+}
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index 90a9b6dc3..53093f827 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -52,7 +52,7 @@ repos:
args: ["--baseline", ".secrets.baseline"]
additional_dependencies: ["gibberish-detector"]
- repo: https://github.com/astral-sh/uv-pre-commit
- rev: 0.9.7
+ rev: 0.11.6 # GHSA-pjjw-68hj-v9mw; matches [tool.uv] required-version
hooks:
- id: uv-lock
- repo: local
diff --git a/.vscode/extensions.json b/.vscode/extensions.json
index afff625e2..56cafd1c2 100644
--- a/.vscode/extensions.json
+++ b/.vscode/extensions.json
@@ -2,8 +2,7 @@
"recommendations": [
"bierner.markdown-mermaid",
"charliermarsh.ruff",
- "codecov.codecov",
- "daelonsuzuka.nicegui",
+"daelonsuzuka.nicegui",
"donjayamanne.python-environment-manager",
"fill-labs.dependi",
"github.vscode-github-actions",
diff --git a/API_REFERENCE_v1.md b/API_REFERENCE_v1.md
index d9d53c641..a3dc4d2a2 100644
--- a/API_REFERENCE_v1.md
+++ b/API_REFERENCE_v1.md
@@ -42,15 +42,12 @@ Base URLs:
```python
import requests
-headers = {
- 'Accept': 'application/json',
- 'Authorization': 'Bearer {access-token}'
-}
-r = requests.get('/api/v1/applications', headers = headers)
+headers = {"Accept": "application/json", "Authorization": "Bearer {access-token}"}
-print(r.json())
+r = requests.get("/api/v1/applications", headers=headers)
+print(r.json())
```
```javascript
@@ -208,15 +205,12 @@ OAuth2AuthorizationCodeBearer
```python
import requests
-headers = {
- 'Accept': 'application/json',
- 'Authorization': 'Bearer {access-token}'
-}
-r = requests.get('/api/v1/applications/{application_id}', headers = headers)
+headers = {"Accept": "application/json", "Authorization": "Bearer {access-token}"}
-print(r.json())
+r = requests.get("/api/v1/applications/{application_id}", headers=headers)
+print(r.json())
```
```javascript
@@ -295,15 +289,12 @@ OAuth2AuthorizationCodeBearer
```python
import requests
-headers = {
- 'Accept': 'application/json',
- 'Authorization': 'Bearer {access-token}'
-}
-r = requests.get('/api/v1/applications/{application_id}/versions/{version}', headers = headers)
+headers = {"Accept": "application/json", "Authorization": "Bearer {access-token}"}
-print(r.json())
+r = requests.get("/api/v1/applications/{application_id}/versions/{version}", headers=headers)
+print(r.json())
```
```javascript
@@ -571,15 +562,12 @@ OAuth2AuthorizationCodeBearer
```python
import requests
-headers = {
- 'Accept': 'application/json',
- 'Authorization': 'Bearer {access-token}'
-}
-r = requests.get('/api/v1/runs', headers = headers)
+headers = {"Accept": "application/json", "Authorization": "Bearer {access-token}"}
-print(r.json())
+r = requests.get("/api/v1/runs", headers=headers)
+print(r.json())
```
```javascript
@@ -962,16 +950,12 @@ OAuth2AuthorizationCodeBearer
```python
import requests
-headers = {
- 'Content-Type': 'application/json',
- 'Accept': 'application/json',
- 'Authorization': 'Bearer {access-token}'
-}
-r = requests.post('/api/v1/runs', headers = headers)
+headers = {"Content-Type": "application/json", "Accept": "application/json", "Authorization": "Bearer {access-token}"}
-print(r.json())
+r = requests.post("/api/v1/runs", headers=headers)
+print(r.json())
```
```javascript
@@ -1188,15 +1172,12 @@ OAuth2AuthorizationCodeBearer
```python
import requests
-headers = {
- 'Accept': 'application/json',
- 'Authorization': 'Bearer {access-token}'
-}
-r = requests.get('/api/v1/runs/{run_id}', headers = headers)
+headers = {"Accept": "application/json", "Authorization": "Bearer {access-token}"}
-print(r.json())
+r = requests.get("/api/v1/runs/{run_id}", headers=headers)
+print(r.json())
```
```javascript
@@ -1298,15 +1279,12 @@ OAuth2AuthorizationCodeBearer
```python
import requests
-headers = {
- 'Accept': 'application/json',
- 'Authorization': 'Bearer {access-token}'
-}
-r = requests.post('/api/v1/runs/{run_id}/cancel', headers = headers)
+headers = {"Accept": "application/json", "Authorization": "Bearer {access-token}"}
-print(r.json())
+r = requests.post("/api/v1/runs/{run_id}/cancel", headers=headers)
+print(r.json())
```
```javascript
@@ -1380,15 +1358,12 @@ OAuth2AuthorizationCodeBearer
```python
import requests
-headers = {
- 'Accept': 'application/json',
- 'Authorization': 'Bearer {access-token}'
-}
-r = requests.get('/api/v1/runs/{run_id}/items', headers = headers)
+headers = {"Accept": "application/json", "Authorization": "Bearer {access-token}"}
-print(r.json())
+r = requests.get("/api/v1/runs/{run_id}/items", headers=headers)
+print(r.json())
```
```javascript
@@ -1798,15 +1773,12 @@ OAuth2AuthorizationCodeBearer
```python
import requests
-headers = {
- 'Accept': 'application/json',
- 'Authorization': 'Bearer {access-token}'
-}
-r = requests.get('/api/v1/runs/{run_id}/items/{external_id}', headers = headers)
+headers = {"Accept": "application/json", "Authorization": "Bearer {access-token}"}
-print(r.json())
+r = requests.get("/api/v1/runs/{run_id}/items/{external_id}", headers=headers)
+print(r.json())
```
```javascript
@@ -1899,15 +1871,12 @@ OAuth2AuthorizationCodeBearer
```python
import requests
-headers = {
- 'Accept': 'application/json',
- 'Authorization': 'Bearer {access-token}'
-}
-r = requests.get('/api/v1/runs/{run_id}/artifacts/{artifact_id}/file', headers = headers)
+headers = {"Accept": "application/json", "Authorization": "Bearer {access-token}"}
-print(r.json())
+r = requests.get("/api/v1/runs/{run_id}/artifacts/{artifact_id}/file", headers=headers)
+print(r.json())
```
```javascript
@@ -1983,15 +1952,12 @@ OAuth2AuthorizationCodeBearer
```python
import requests
-headers = {
- 'Accept': 'application/json',
- 'Authorization': 'Bearer {access-token}'
-}
-r = requests.delete('/api/v1/runs/{run_id}/artifacts', headers = headers)
+headers = {"Accept": "application/json", "Authorization": "Bearer {access-token}"}
-print(r.json())
+r = requests.delete("/api/v1/runs/{run_id}/artifacts", headers=headers)
+print(r.json())
```
```javascript
@@ -2062,16 +2028,12 @@ OAuth2AuthorizationCodeBearer
```python
import requests
-headers = {
- 'Content-Type': 'application/json',
- 'Accept': 'application/json',
- 'Authorization': 'Bearer {access-token}'
-}
-r = requests.put('/api/v1/runs/{run_id}/custom-metadata', headers = headers)
+headers = {"Content-Type": "application/json", "Accept": "application/json", "Authorization": "Bearer {access-token}"}
-print(r.json())
+r = requests.put("/api/v1/runs/{run_id}/custom-metadata", headers=headers)
+print(r.json())
```
```javascript
@@ -2171,16 +2133,12 @@ OAuth2AuthorizationCodeBearer
```python
import requests
-headers = {
- 'Content-Type': 'application/json',
- 'Accept': 'application/json',
- 'Authorization': 'Bearer {access-token}'
-}
-r = requests.put('/api/v1/runs/{run_id}/items/{external_id}/custom-metadata', headers = headers)
+headers = {"Content-Type": "application/json", "Accept": "application/json", "Authorization": "Bearer {access-token}"}
-print(r.json())
+r = requests.put("/api/v1/runs/{run_id}/items/{external_id}/custom-metadata", headers=headers)
+print(r.json())
```
```javascript
@@ -2280,15 +2238,12 @@ OAuth2AuthorizationCodeBearer
```python
import requests
-headers = {
- 'Accept': 'application/json',
- 'Authorization': 'Bearer {access-token}'
-}
-r = requests.get('/api/v1/me', headers = headers)
+headers = {"Accept": "application/json", "Authorization": "Bearer {access-token}"}
-print(r.json())
+r = requests.get("/api/v1/me", headers=headers)
+print(r.json())
```
```javascript
diff --git a/CLAUDE.md b/CLAUDE.md
index 34c3f8017..d0f31ad91 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -32,7 +32,7 @@ If you write code yourself, it is a strict requirement to validate your work on
If you you are creating a pull request yourself:
-* Add a label skip:test_long_running, to skip running long running tests. This is important because some tests in this repository are marked as long_running and can take a significant amount of time to complete. By adding this label, you help ensure that the CI pipeline runs efficiently and avoids unnecessary delays.
+* Add a label skip:test:long_running, to skip running long running tests. This is important because some tests in this repository are marked as long_running and can take a significant amount of time to complete. By adding this label, you help ensure that the CI pipeline runs efficiently and avoids unnecessary delays.
## Module Documentation Index
@@ -293,10 +293,7 @@ for app in client.applications.list():
print(app.application_id)
# Submit run
-run = client.runs.create(
- application_id="heta",
- files=["slide.svs"]
-)
+run = client.runs.create(application_id="heta", files=["slide.svs"])
```
### Service Discovery Pattern
@@ -464,6 +461,7 @@ This ensures the JSON Schema is automatically regenerated during documentation b
```python
from aignostics.utils import BaseService, Health
+
class Service(BaseService):
"""Module service implementation."""
@@ -484,6 +482,7 @@ from ._service import Service
cli = typer.Typer(name="module", help="Module description")
+
@cli.command("action")
def action_command(param: str):
"""Action description."""
@@ -709,8 +708,10 @@ details = run.details()
if details.output.state == RunState.TERMINATED:
if details.output.termination_reason == RunTerminationReason.ALL_ITEMS_PROCESSED:
print(f"✅ Run complete: {details.output.statistics.succeeded} items succeeded")
- print(f"❌ Failures: {details.output.statistics.user_error} user errors, "
- f"{details.output.statistics.system_error} system errors")
+ print(
+ f"❌ Failures: {details.output.statistics.user_error} user errors, "
+ f"{details.output.statistics.system_error} system errors"
+ )
```
See `platform/CLAUDE.md` for complete state machine diagrams and migration guide.
@@ -866,10 +867,10 @@ The test suite uses pytest-xdist for parallel execution with intelligent distrib
```python
# Worker factors control parallelism
XDIST_WORKER_FACTOR = {
- "unit": 0.0, # No parallelization (fast, no overhead needed)
- "integration": 0.2, # 20% of logical CPUs
- "e2e": 1.0, # 100% of logical CPUs (I/O bound)
- "default": 1.0 # 100% for mixed test runs
+ "unit": 0.0, # No parallelization (fast, no overhead needed)
+ "integration": 0.2, # 20% of logical CPUs
+ "e2e": 1.0, # 100% of logical CPUs (I/O bound)
+ "default": 1.0, # 100% for mixed test runs
}
# Calculate workers: max(1, int(cpu_count * factor))
@@ -1009,6 +1010,7 @@ uv run pytest -m "e2e and not long_running" -v
import pytest
from unittest.mock import patch
+
@pytest.mark.unit
def test_sdk_metadata_minimal(monkeypatch):
"""Test SDK metadata with clean environment."""
@@ -1163,7 +1165,7 @@ git push origin feat/my-feature
gh pr create --title "feat: add operation caching" --body "Description..."
# IMPORTANT: Add label to skip long-running tests
-gh pr edit --add-label "skip:test_long_running"
+gh pr edit --add-label "skip:test:long_running"
```
**PR triggers:**
@@ -1367,7 +1369,7 @@ git commit -m "docs: update README [skip ci]"
git commit -m "skip:ci: work in progress"
# Add PR label to skip long-running tests
-gh pr edit --add-label "skip:test_long_running"
+gh pr edit --add-label "skip:test:long_running"
```
### IDE Setup Recommendations
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index ddb434ded..4ca08d935 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -269,18 +269,12 @@ client = Client()
custom_metadata = {
"experiment_id": "exp-2025-001",
"dataset_version": "v2.1",
- "custom_flags": {
- "enable_feature_x": True
- }
+ "custom_flags": {"enable_feature_x": True},
}
# Submit run with custom metadata
# SDK metadata is automatically added under the "sdk" key
-run = client.runs.submit(
- application_id="your-app",
- items=[...],
- custom_metadata=custom_metadata
-)
+run = client.runs.submit(application_id="your-app", items=[...], custom_metadata=custom_metadata)
```
The SDK will merge your custom metadata with its own tracking metadata, ensuring both are included in the run submission. The SDK metadata is always placed under the `sdk` key to avoid conflicts with your custom fields.
diff --git a/Dockerfile b/Dockerfile
index caabc7768..ab056b388 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -5,7 +5,7 @@ FROM python:3.14.3-slim-trixie AS base
FROM base AS builder
# Copy in UV
-COPY --from=ghcr.io/astral-sh/uv:0.9.18 /uv /bin/uv
+COPY --from=ghcr.io/astral-sh/uv:0.11.7 /uv /bin/uv
# We use the system interpreter managed by uv
ENV UV_PYTHON_DOWNLOADS=0
diff --git a/Makefile b/Makefile
index ad36c36ff..53e1c1bca 100644
--- a/Makefile
+++ b/Makefile
@@ -162,7 +162,8 @@ gui_watch:
uv run runner/gui_watch.py
profile:
- uv run --all-extras python -m scalene runner/scalene.py
+ mkdir -p tmp
+ uv run --all-extras python -m scalene run runner/scalene.py --outfile tmp/scalene-profile.json && uv run --all-extras python -m scalene view tmp/scalene-profile.json
# Signing: https://gist.github.com/bpteague/750906b9a02094e7389427d308ba1002
dist_native:
diff --git a/README.md b/README.md
index d5fb4c276..b728aa1cc 100644
--- a/README.md
+++ b/README.md
@@ -477,24 +477,24 @@ from aignostics import platform
client = platform.Client()
# submit an application run
application_run = client.runs.submit(
- application_id="test-app",
- items=[
- platform.InputItem(
- external_id="slide-1",
- input_artifacts=[
- platform.InputArtifact(
- name="whole_slide_image",
- download_url="",
- metadata={
- "checksum_base64_crc32c": "AAAAAA==",
- "resolution_mpp": 0.25,
- "width_px": 1000,
- "height_px": 1000,
- },
- )
- ],
- ),
- ],
+ application_id="test-app",
+ items=[
+ platform.InputItem(
+ external_id="slide-1",
+ input_artifacts=[
+ platform.InputArtifact(
+ name="whole_slide_image",
+ download_url="",
+ metadata={
+ "checksum_base64_crc32c": "AAAAAA==",
+ "resolution_mpp": 0.25,
+ "width_px": 1000,
+ "height_px": 1000,
+ },
+ )
+ ],
+ ),
+ ],
)
# wait for the results and download incrementally as they become available
application_run.download_to_folder("path/to/download/folder")
@@ -571,21 +571,23 @@ the latest version will be used automatically. Additionally, you need to define
want to process in the run. The input items are defined as follows:
```python
-platform.InputItem(
- external_id="1",
- input_artifacts=[
- platform.InputArtifact(
- name="whole_slide_image", # defined by the application version's input artifact schema
- download_url="",
- metadata={ # defined by the application version's input artifact schema
- "checksum_base64_crc32c": "N+LWCg==",
- "resolution_mpp": 0.46499982,
- "width_px": 3728,
- "height_px": 3640,
- },
- )
- ],
-),
+(
+ platform.InputItem(
+ external_id="1",
+ input_artifacts=[
+ platform.InputArtifact(
+ name="whole_slide_image", # defined by the application version's input artifact schema
+ download_url="",
+ metadata={ # defined by the application version's input artifact schema
+ "checksum_base64_crc32c": "N+LWCg==",
+ "resolution_mpp": 0.46499982,
+ "width_px": 3728,
+ "height_px": 3640,
+ },
+ )
+ ],
+ ),
+)
```
For each item you want to process, you need to provide a unique `reference`
diff --git a/SECURITY.md b/SECURITY.md
index b4203261b..b6a044cc5 100644
--- a/SECURITY.md
+++ b/SECURITY.md
@@ -31,6 +31,8 @@ d. **[trivy](https://trivy.dev/latest/)**: Pre commit to GitHub scans Python dep
e. **[ox.security](https://www.ox.security/)**: Monitors dependencies for vulnerabilities pre and post release on GitHub.
+How we handle vulnerabilities in our Python dependency supply chain — including the default path of raising lower bounds in `pyproject.toml`, and the list of advisories we have consciously accepted because no upstream fix is available yet — is documented in [SUPPLY_CHAIN_VULNERABILITIES.md](SUPPLY_CHAIN_VULNERABILITIES.md).
+
### 2. License Compliance Checks and Software Bill of Materials (SBOM)
a. **[pip-licenses](https://pypi.org/project/pip-licenses/)**: Inspects and matches the licenses of all dependencies with allow list to ensure compliance with licensing requirements and avoid using components with problematic licenses. `licenses.csv`, `licenses.json` and `licenses_grouped.json` published [per release](https://github.com/aignostics/python-sdk/releases).
diff --git a/SUPPLY_CHAIN_VULNERABILITIES.md b/SUPPLY_CHAIN_VULNERABILITIES.md
new file mode 100644
index 000000000..cc14b1b30
--- /dev/null
+++ b/SUPPLY_CHAIN_VULNERABILITIES.md
@@ -0,0 +1,132 @@
+# Supply-Chain Vulnerabilities
+
+This document describes how aignostics handles vulnerabilities in its Python
+dependency supply chain.
+
+Aignostics is consumed both as an application (`uvx aignostics`, or the
+Launchpad) and as a library that users add to their own project with
+`uv add aignostics`, `pip install aignostics`, or any other resolver. In
+both cases the user's tooling picks dependency versions against the
+dependency metadata we publish with the aignostics package — our own
+lockfile and any development-only constraints we apply locally are
+invisible to consumers. The only way to keep a consumer from resolving a
+known-vulnerable version of a dependency is therefore to set an
+appropriate lower bound on the dependency metadata we publish, including
+on transitive dependencies.
+
+This document covers:
+
+- **How we protect consumers** — our default response when a scanner
+ reports an advisory, and the fallback path when no upstream fix is
+ available yet.
+- **Active acceptances** — the (short) list of advisories we have
+ consciously accepted because no upstream fix exists or the
+ vulnerability is not exploitable in how aignostics uses the package.
+ This is where **the actual residual risk lives**: each row records
+ severity, scope, downstream-exposure assessment, rationale, and the
+ condition under which the acceptance expires.
+- **Enforced lower bounds for CVE protection** — the complete catalog of
+ lower bounds we currently set specifically to shield consumers from
+ advisories that already have an upstream fix.
+
+See [SECURITY.md](SECURITY.md) for the scanners that feed this process
+(`pip-audit`, Dependabot, Renovate, trivy, ox.security).
+
+## How we protect consumers
+
+This is sharper than the default practice in the Python ecosystem, where
+many libraries constrain only their direct dependencies or rely on their
+own lockfile for protection. We deliberately maintain explicit lower
+bounds on direct *and* transitive dependencies, annotated with the
+relevant CVE / GHSA id, so both `uvx aignostics` and `uv add aignostics`
+give consumers a dependency tree free of the advisories we are aware of.
+
+Our policy per finding:
+
+- **Upstream fix available** — raise the lower bound of the affected
+ package with an inline CVE / GHSA comment and refresh our lockfile.
+ The bound goes on the existing line for direct dependencies, and in
+ the transitive-overrides block of the same section for transitive
+ ones. If the package is only reachable through an optional extra the
+ bound goes in that extra; if the package is genuinely dev-only it goes
+ in the dev-group constraints.
+- **No upstream fix yet, or not exploitable in our use** — add the
+ advisory to the "Active acceptances" table below (with severity,
+ downstream-exposure assessment, rationale, and a removal condition)
+ and suppress it in `pip-audit` so it does not block CI. We never
+ silently ignore a finding.
+
+## Active acceptances
+
+This is where the actual residual risk lives. Each row is an advisory for
+which no upstream fix has been released yet — or for which the
+vulnerability is not exploitable in how aignostics uses the package —
+together with severity, exposure assessment, rationale, and the condition
+under which the acceptance expires.
+
+- **Applies to** says when the vulnerable package reaches a consumer's
+ tree: *always* (every install), *with the `` extra* (only consumers
+ who install that extra), or *dev only* (never reaches consumers;
+ affects our tooling only).
+- **Downstream exposure** says whether accepting the advisory leaves
+ consumers at risk: *None* (e.g. the package is dev-only for us),
+ *Partial* (only if a specific extra is installed), or *Full* (every
+ consumer install inherits the vulnerable version).
+
+| Advisory | Package (affected) | Severity | Applies to | Downstream exposure | Published | Accepted | Revisit by | Fix status | Rationale | Removal condition | Accepted via |
+| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |
+| _No active acceptances. All known advisories that affect aignostics or its dependencies have an upstream fix that is reflected as an enforced lower bound below._ | — | — | — | — | — | — | — | — | — | — | — |
+
+"Revisit by" is a soft deadline: when it passes, we re-check whether the
+upstream fix landed or whether a newer advisory superseded this one. For
+dev-only entries we revisit quarterly; for runtime entries with a known
+upcoming fix, monthly.
+
+## Enforced lower bounds for CVE protection
+
+Every lower bound below is set with an inline CVE / GHSA comment so it is
+visible to anyone reading the project metadata. These are the constraints
+that prevent downstream consumers from resolving a known-vulnerable
+version.
+
+- **Applies to** tells you when the constraint reaches a consumer's
+ dependency tree: *always* (affects every `uvx aignostics` and
+ `uv add aignostics`), *with the `` extra* (affects only consumers
+ who install that extra), or *dev only* (never reaches a consumer;
+ applies to our own tooling).
+- **Severity** is the highest CVSS rating among the advisories protected
+ against, as reported by NVD.
+- **Since** is the calendar date on which the lower bound was introduced
+ into `main`.
+
+| Package | Constraint | Protects against | Severity | Applies to | Since |
+| --- | --- | --- | --- | --- | --- |
+| `pip` | `>=26.1` | [CVE-2025-8869](https://nvd.nist.gov/vuln/detail/CVE-2025-8869) (≥25.3); [CVE-2026-3219](https://nvd.nist.gov/vuln/detail/CVE-2026-3219) / [GHSA-58qw-9mgm-455v](https://github.com/advisories/GHSA-58qw-9mgm-455v) (≥26.1, [pypa/pip#13870](https://github.com/pypa/pip/pull/13870)) | Medium | dev only | 2026-04-01 (>=5.3 — ineffective); 2026-04-24 raised to ≥25.3; 2026-04-27 raised to ≥26.1 |
+| `nicegui[native]` | `>=3.11.0,<4` | [CVE-2026-21871](https://nvd.nist.gov/vuln/detail/CVE-2026-21871), [CVE-2026-21873](https://nvd.nist.gov/vuln/detail/CVE-2026-21873), [CVE-2026-21874](https://nvd.nist.gov/vuln/detail/CVE-2026-21874) (≥3.5.0); [CVE-2026-25516](https://nvd.nist.gov/vuln/detail/CVE-2026-25516) (≥3.7.0); [CVE-2026-27156](https://nvd.nist.gov/vuln/detail/CVE-2026-27156) (≥3.8.0); [CVE-2026-33332](https://nvd.nist.gov/vuln/detail/CVE-2026-33332) (≥3.9.0); [CVE-2026-39844](https://nvd.nist.gov/vuln/detail/CVE-2026-39844) (≥3.10.0) | Medium | always | 2026-01-09 (≥3.5.0); 2026-04-24 raised to ≥3.9.0; 2026-04-26 raised to ≥3.11.0 (#531) |
+| `pyjwt[crypto]` | `>=2.12.0,<3` | [CVE-2026-32597](https://nvd.nist.gov/vuln/detail/CVE-2026-32597) | High | always | 2026-04-24 |
+| `requests` | `>=2.33.0,<3` | [CVE-2026-25645](https://nvd.nist.gov/vuln/detail/CVE-2026-25645) | Medium | always | 2026-03-26 |
+| `urllib3` | `>=2.6.3,<3` | [CVE-2026-21441](https://nvd.nist.gov/vuln/detail/CVE-2026-21441) | Medium | always | 2026-01-08 |
+| `h11` | `>=0.16.0` | [CVE-2025-43859](https://nvd.nist.gov/vuln/detail/CVE-2025-43859) | Critical | always | 2025-12-10 |
+| `tornado` | `>=6.5.5` | [CVE-2025-47287](https://nvd.nist.gov/vuln/detail/CVE-2025-47287) (≥6.5.0); [GHSA-78cv-mqj4-43f7](https://github.com/advisories/GHSA-78cv-mqj4-43f7) (≥6.5.5) | High | always | 2025-12-10 (≥6.5.0); 2026-04-24 raised to ≥6.5.5 |
+| `urllib3` | `>=2.5.0` | [CVE-2025-50181](https://nvd.nist.gov/vuln/detail/CVE-2025-50181), [CVE-2025-50182](https://nvd.nist.gov/vuln/detail/CVE-2025-50182) | Medium | always | 2025-12-10 |
+| `pillow` | `>=12.2.0` | [CVE-2025-48379](https://nvd.nist.gov/vuln/detail/CVE-2025-48379) (≥11.3.0); [CVE-2026-25990](https://nvd.nist.gov/vuln/detail/CVE-2026-25990) (≥12.1.1); [CVE-2026-40192](https://nvd.nist.gov/vuln/detail/CVE-2026-40192) (≥12.2.0) | High | always | 2025-12-10 (≥11.3.0); 2026-04-24 raised to ≥12.2.0 |
+| `aiohttp` | `>=3.13.4` | [CVE-2025-53643](https://nvd.nist.gov/vuln/detail/CVE-2025-53643), CVE-2025-69223..69230 (≥3.13.3); [CVE-2026-22815](https://nvd.nist.gov/vuln/detail/CVE-2026-22815) (≥3.13.4) | High | always | 2026-01-06 (≥3.13.3); 2026-04-24 raised to ≥3.13.4 |
+| `starlette` | `>=0.49.1` | [CVE-2025-54121](https://nvd.nist.gov/vuln/detail/CVE-2025-54121) (≥0.47.2); [GHSA-7f5h-v6xp-fcq8](https://github.com/advisories/GHSA-7f5h-v6xp-fcq8) (≥0.49.1) | Medium | always | 2025-12-10 |
+| `lxml` | `>=6.1.0` | [CVE-2026-41066](https://nvd.nist.gov/vuln/detail/CVE-2026-41066) | High | always | 2025-12-10 (≥6.0.2); 2026-04-24 raised to ≥6.1.0 |
+| `filelock` | `>=3.20.3` | [CVE-2025-68146](https://nvd.nist.gov/vuln/detail/CVE-2025-68146) (≥3.20.1); [CVE-2026-22701](https://nvd.nist.gov/vuln/detail/CVE-2026-22701) (≥3.20.3) | Medium | always | 2025-12-17 (≥3.20.1); 2026-04-24 raised to ≥3.20.3 |
+| `marshmallow` | `>=3.26.2` | [CVE-2025-68480](https://nvd.nist.gov/vuln/detail/CVE-2025-68480) | Medium | always | 2025-12-23 |
+| `pygments` | `>=2.20.0` | [CVE-2026-4539](https://nvd.nist.gov/vuln/detail/CVE-2026-4539) | Medium | always | 2026-04-24 |
+| `cryptography` | `>=46.0.7` | [CVE-2026-39892](https://nvd.nist.gov/vuln/detail/CVE-2026-39892) | Medium | always | 2026-04-24 |
+| `pydicom` | `>=3.0.2` | [CVE-2026-32711](https://nvd.nist.gov/vuln/detail/CVE-2026-32711) | High | always | 2026-04-24 |
+| `pyasn1` | `>=0.6.3` | [CVE-2026-30922](https://nvd.nist.gov/vuln/detail/CVE-2026-30922) | High | always | 2026-04-24 |
+| `lxml-html-clean` | `>=0.4.4` | [CVE-2026-28348](https://nvd.nist.gov/vuln/detail/CVE-2026-28348), [CVE-2026-28350](https://nvd.nist.gov/vuln/detail/CVE-2026-28350) | Medium | always | 2026-04-24 |
+| `python-multipart` | `>=0.0.26` | [CVE-2026-24486](https://nvd.nist.gov/vuln/detail/CVE-2026-24486) (≥0.0.22); [CVE-2026-40347](https://nvd.nist.gov/vuln/detail/CVE-2026-40347) (≥0.0.26) | High | always | 2026-04-24 |
+| `protobuf` | `>=6.33.5` | [CVE-2026-0994](https://nvd.nist.gov/vuln/detail/CVE-2026-0994) | High | always | 2026-04-24 |
+| `nbconvert` | `>=7.17.1` | [CVE-2025-53000](https://nvd.nist.gov/vuln/detail/CVE-2025-53000) (≥7.17.0); [CVE-2026-39377](https://nvd.nist.gov/vuln/detail/CVE-2026-39377), [CVE-2026-39378](https://nvd.nist.gov/vuln/detail/CVE-2026-39378) (≥7.17.1) | High | with the `jupyter` extra | 2026-04-24 (≥7.17.1) |
+| `jupyter-core` | `>=5.8.1` | [CVE-2025-30167](https://nvd.nist.gov/vuln/detail/CVE-2025-30167) | High | with the `jupyter` extra | 2025-12-10 |
+| `jupyterlab` | `>=4.4.9` | [CVE-2025-59842](https://nvd.nist.gov/vuln/detail/CVE-2025-59842) | Low | with the `jupyter` extra | 2025-12-10 |
+| `marimo` | `>=0.23.0,<1` | [GHSA-2679-6mx9-h9xc](https://github.com/advisories/GHSA-2679-6mx9-h9xc) | Medium | with the `marimo` extra | 2026-04-24 |
+| `uv` | `>=0.11.6` | [CVE-2025-54368](https://nvd.nist.gov/vuln/detail/CVE-2025-54368), [GHSA-w476-p2h3-79g9](https://github.com/advisories/GHSA-w476-p2h3-79g9), [GHSA-pqhf-p39g-3x64](https://github.com/advisories/GHSA-pqhf-p39g-3x64) (≥0.9.7); [GHSA-pjjw-68hj-v9mw](https://github.com/advisories/GHSA-pjjw-68hj-v9mw) (≥0.11.6) | Medium | dev only | 2025-12-10 (≥0.9.7); 2026-04-24 raised to ≥0.11.6 |
+| `pytest` | `>=9.0.3,<10` | [CVE-2025-71176](https://nvd.nist.gov/vuln/detail/CVE-2025-71176) | Medium | dev only | 2026-04-24 |
+| `virtualenv` | `>=20.36.1` | [pypa/virtualenv#3013](https://github.com/pypa/virtualenv/pull/3013) TOCTOU fix; bundles filelock ≥3.20.1 for [CVE-2025-68146](https://nvd.nist.gov/vuln/detail/CVE-2025-68146) | Medium | dev only | 2026-04-24 |
+| `fonttools` | `>=4.60.2` | [CVE-2025-66034](https://nvd.nist.gov/vuln/detail/CVE-2025-66034) / [GHSA-768j-98cg-p3fv](https://github.com/advisories/GHSA-768j-98cg-p3fv) | Medium | dev only | 2025-12-10 |
diff --git a/compass.yml b/compass.yml
index 164219174..1979bc621 100644
--- a/compass.yml
+++ b/compass.yml
@@ -1,17 +1,45 @@
name: python-sdk
id: ari:cloud:compass:fff788d2-8a2a-4c36-a884-dde2bb4a2b49:component/f65912bc-77bd-4e1f-b333-1a9e8f0ac32c/aca547e5-2577-4f9d-9aaa-cf360acc976c
-description: 🔬 Python SDK providing access to the Aignostics Platform. Includes Aignostics Launchpad (Desktop Application), Aignostics CLI (Command-Line Interface), example notebooks, and Aignostics Client Library.
+description: 🔬 Python SDK providing access to the Aignostics Platform. Includes
+ Aignostics Launchpad (Desktop Application), Aignostics CLI (Command-Line
+ Interface), example notebooks, and Aignostics Client Library.
configVersion: 1
typeId: APPLICATION
ownerId: ari:cloud:identity::team/b1cfb598-3138-4c2a-8b6a-19fb1078645b
fields:
- tier: 4
+ tier: 1
+ lifecycle: Active
+ isMonorepoProject: false
links:
- name: null
type: REPOSITORY
url: https://github.com/aignostics/python-sdk
+ - name: Jira Board
+ type: PROJECT
+ url: https://aignx.atlassian.net/jira/software/c/projects/PYSDK/boards/1799
+ - name: Status Page
+ type: DASHBOARD
+ url: https://status.aignostics.com
+ - name: '#python-sdk-dev'
+ type: CHAT_CHANNEL
+ url: https://slack.com/app_redirect?channel=C098D8MH431
+ - name: '#python-sdk-notifications'
+ type: CHAT_CHANNEL
+ url: https://slack.com/app_redirect?channel=C0AUPTA5QF9
+ - name: '#python-sdk-announcements'
+ type: CHAT_CHANNEL
+ url: https://slack.com/app_redirect?channel=C08TN9NEY3Z
+ - name: Ketryx Project
+ type: OTHER_LINK
+ url: https://app.ketryx.com/projects/KXPRJ2Q4PA8AADY975SFMKF276TYV75
+ - name: Sentry
+ type: DASHBOARD
+ url: https://aignostics.sentry.io/projects/python-sdk/
relationships:
- DEPENDS_ON: []
+ DEPENDS_ON:
+ - 'ari:cloud:compass:fff788d2-8a2a-4c36-a884-dde2bb4a2b49:component/f65912bc-77bd-4e1f-b333-1a9e8f0ac32c/638f89c6-24a3-48f4-90ad-1b09ef8d32c6'
+ - 'ari:cloud:compass:fff788d2-8a2a-4c36-a884-dde2bb4a2b49:component/f65912bc-77bd-4e1f-b333-1a9e8f0ac32c/8a5a2394-89b7-4c84-9b14-947ce2cf9fdd'
+ - 'ari:cloud:compass:fff788d2-8a2a-4c36-a884-dde2bb4a2b49:component/f65912bc-77bd-4e1f-b333-1a9e8f0ac32c/b2f7a1a1-112c-4d68-aa3c-7ce0c04eda2d'
labels:
- aignostics
- atlas
@@ -38,25 +66,25 @@ customFields:
value: false
- name: Component Key
type: text
- value: null
+ value: python-sdk
- name: Deployment Branch
type: text
- value: null
+ value: release/*
- name: Development Branch
type: text
- value: null
+ value: main
- name: FinOps Label
type: text
value: null
- name: Has external API
type: boolean
- value: false
+ value: true
- name: Has external CLI
type: boolean
- value: false
+ value: true
- name: Has external graphical UI
type: boolean
- value: false
+ value: true
- name: Has internal API
type: boolean
value: false
@@ -68,10 +96,13 @@ customFields:
value: false
- name: Is independently deployable
type: boolean
- value: false
+ value: true
+ - name: Is open source
+ type: boolean
+ value: true
- name: Path in Repository
type: text
- value: null
+ value: /
- name: Process Level
type: single_select
- value: null
+ value: 7a2a380d-da85-476f-ae5d-dfc034889b61
diff --git a/docs/partials/README_footer.md b/docs/partials/README_footer.md
index 26a29209b..6c2523bf5 100644
--- a/docs/partials/README_footer.md
+++ b/docs/partials/README_footer.md
@@ -3,6 +3,9 @@
1. Inspect our
[security policy](https://aignostics.readthedocs.io/en/latest/security.html)
with detailed documentation of checks, tools and principles.
+ How we handle vulnerabilities in our Python dependency supply chain is
+ documented in
+ [SUPPLY_CHAIN_VULNERABILITIES.md](https://github.com/aignostics/python-sdk/blob/main/SUPPLY_CHAIN_VULNERABILITIES.md).
1. Inspect how we achieve
[operational excellence](https://aignostics.readthedocs.io/en/latest/operational_excellence.html)
with information on our modern toolchain and software architecture.
diff --git a/docs/partials/README_main.md b/docs/partials/README_main.md
index 65a347f9f..9232a42bb 100644
--- a/docs/partials/README_main.md
+++ b/docs/partials/README_main.md
@@ -462,24 +462,24 @@ from aignostics import platform
client = platform.Client()
# submit an application run
application_run = client.runs.submit(
- application_id="test-app",
- items=[
- platform.InputItem(
- external_id="slide-1",
- input_artifacts=[
- platform.InputArtifact(
- name="whole_slide_image",
- download_url="",
- metadata={
- "checksum_base64_crc32c": "AAAAAA==",
- "resolution_mpp": 0.25,
- "width_px": 1000,
- "height_px": 1000,
- },
- )
- ],
- ),
- ],
+ application_id="test-app",
+ items=[
+ platform.InputItem(
+ external_id="slide-1",
+ input_artifacts=[
+ platform.InputArtifact(
+ name="whole_slide_image",
+ download_url="",
+ metadata={
+ "checksum_base64_crc32c": "AAAAAA==",
+ "resolution_mpp": 0.25,
+ "width_px": 1000,
+ "height_px": 1000,
+ },
+ )
+ ],
+ ),
+ ],
)
# wait for the results and download incrementally as they become available
application_run.download_to_folder("path/to/download/folder")
@@ -556,21 +556,23 @@ the latest version will be used automatically. Additionally, you need to define
want to process in the run. The input items are defined as follows:
```python
-platform.InputItem(
- external_id="1",
- input_artifacts=[
- platform.InputArtifact(
- name="whole_slide_image", # defined by the application version's input artifact schema
- download_url="",
- metadata={ # defined by the application version's input artifact schema
- "checksum_base64_crc32c": "N+LWCg==",
- "resolution_mpp": 0.46499982,
- "width_px": 3728,
- "height_px": 3640,
- },
- )
- ],
-),
+(
+ platform.InputItem(
+ external_id="1",
+ input_artifacts=[
+ platform.InputArtifact(
+ name="whole_slide_image", # defined by the application version's input artifact schema
+ download_url="",
+ metadata={ # defined by the application version's input artifact schema
+ "checksum_base64_crc32c": "N+LWCg==",
+ "resolution_mpp": 0.46499982,
+ "width_px": 3728,
+ "height_px": 3640,
+ },
+ )
+ ],
+ ),
+)
```
For each item you want to process, you need to provide a unique `reference`
diff --git a/noxfile.py b/noxfile.py
index 48f2040ed..a4c51d5c4 100644
--- a/noxfile.py
+++ b/noxfile.py
@@ -145,23 +145,16 @@ def audit(session: nox.Session) -> None:
"""Run security audit and license checks."""
_setup_venv(session)
- # pip-audit to check for vulnerabilities
+ # pip-audit to check for vulnerabilities.
+ # Every --ignore-vuln entry must correspond to a row in SUPPLY_CHAIN_VULNERABILITIES.md
+ # with rationale, scope, downstream-exposure assessment, and removal condition.
try:
session.run(
- # TODO(Helmut): Ignore pip vuln until pip achieved to build v5.3
"pip-audit",
"-f",
"json",
"-o",
"reports/vulnerabilities.json",
- "--ignore-vuln",
- "GHSA-4xh5-x5gv-qwph", # https://pyinstaller.org/en/stable/license.html
- "--ignore-vuln",
- "CVE-2025-53000", # no fix available
- "--ignore-vuln",
- "CVE-2025-69872", # no fix available
- "--ignore-vuln",
- "CVE-2026-4539", # no fix available
)
except CommandFailed:
_format_json_with_jq(session, "reports/vulnerabilities.json")
diff --git a/pyproject.toml b/pyproject.toml
index 8e990c83e..f45bff877 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -78,7 +78,7 @@ dependencies = [
# From Template
"fastapi[all,standard]>=0.123.10",
"humanize>=4.14.0,<5",
- "nicegui[native]>=3.5.0,<4", # CVE-2026-21871, CVE-2026-21871, CVE-2026-21873, CVE-2026-21874 all require >=3.5.0
+ "nicegui[native]>=3.11.0,<4", # CVE-2026-21871, CVE-2026-21873, CVE-2026-21874 (>=3.5.0); CVE-2026-25516 (>=3.7.0, #418); CVE-2026-27156 (>=3.8.0, #448); CVE-2026-33332 (>=3.9.0, #498); CVE-2026-39844 (>=3.10.0, #531). 3.11.0 fixes async event handler exception leaks and refines ValueChangeEventArguments generics.
"packaging>=26,<27",
"platformdirs>=4.5.1,<5",
"psutil>=7.1.3,<8",
@@ -99,19 +99,19 @@ dependencies = [
"highdicom>=0.26.1,<1; python_version < '3.14'", # transitive dependency pyjpegls not yet supporting Python 3.14
"html-sanitizer>=2.6.0,<3",
"httpx>=0.28.1,<1",
- "idc-index-data==23.8.1",
+ "idc-index-data==23.10.1",
"ijson>=3.4.0.post0,<4",
"jsf>=0.11.2,<1",
"jsonschema[format-nongpl]>=4.25.1,<5",
"loguru>=0.7.3,<1",
"openslide-bin>=4.0.0.10,<5",
"openslide-python>=1.4.3,<2",
- "pandas>=2.3.3,<3",
+ "pandas>=2.3.3,<4",
"platformdirs>=4.3.2,<5",
"procrastinate>=3.5.3",
- "fastparquet>=2025.12.0,<2026.0.0; python_version < '3.14'",
- "pyarrow>=22.0.0,<23; python_version >= '3.14'",
- "pyjwt[crypto]>=2.10.1,<3",
+ "fastparquet>=2026.3.0,<2026.4.0; python_version < '3.14'",
+ "pyarrow>=23.0.1,<24; python_version >= '3.14'",
+ "pyjwt[crypto]>=2.12.0,<3", # CVE-2026-32597 requires >=2.12.0 (Renovate #475)
"python-dateutil>=2.9.0.post0,<3",
# "pywebview[qt6]>=5.4,<6; sys_platform == 'linux'",
"requests>=2.33.0,<3", # CVE-2026-25645 requires >= 2.33.0
@@ -125,19 +125,26 @@ dependencies = [
"urllib3>=2.6.3,<3", # CVE-2026-21441 requires >= 2.6.3
"wsidicom>=0.28.1,<1",
"fastmcp>=3.2.0,<4",
- # Transitive overrides
+ # Transitive overrides (see SUPPLY_CHAIN_VULNERABILITIES.md)
# WARNING: one cannot negate or downgrade a dependency required here. use override-dependencies for that.
"rfc3987; sys_platform == 'never'", # GPLv3
"h11>=0.16.0", # CVE-2025-43859
- "tornado>=6.5.0", # CVE-2025-47287
- "urllib3>=2.5.0", # CVE-2025-50181, CVE-2025-50182,
- "pillow>=11.3.0", # CVE-2025-48379,
- "aiohttp>=3.13.3", # CVE-2025-53643, CVE-2025-69223, CVE-2025-69224, CVE-2025-69228, CVE-2025-69229, CVE-2025-69230, CVE-2025-69226, CVE-2025-69227, CVE-2025-69225
+ "tornado>=6.5.5", # CVE-2025-47287 (>=6.5.0); GHSA-78cv-mqj4-43f7 (>=6.5.5, Renovate #472)
+ "urllib3>=2.5.0", # CVE-2025-50181, CVE-2025-50182
+ "pillow>=12.2.0", # CVE-2025-48379 (>=11.3.0); CVE-2026-25990 (>=12.1.1, Renovate #428); CVE-2026-40192 (>=12.2.0, Renovate #539)
+ "aiohttp>=3.13.4", # CVE-2025-53643, CVE-2025-69223..9 (>=3.13.3); CVE-2026-22815 (>=3.13.4, Renovate #527)
"starlette>=0.47.2", # CVE-2025-54121
"starlette>=0.49.1", # GHSA-7f5h-v6xp-fcq8
- "lxml>=6.0.2", # For python 3.14 pre-built wheels
- "filelock>=3.20.1", # CVE-2025-68146
+ "lxml>=6.1.0", # CVE-2026-41066 (Renovate #556); also required for python 3.14 pre-built wheels
+ "filelock>=3.20.3", # CVE-2025-68146 (>=3.20.1); CVE-2026-22701 (>=3.20.3, Renovate #387)
"marshmallow>=3.26.2", # CVE-2025-68480
+ "pygments>=2.20.0", # CVE-2026-4539 (>=2.20.0); transitive via rich
+ "cryptography>=46.0.7", # CVE-2026-39892 (>=46.0.7); transitive via pyjwt[crypto]
+ "pydicom>=3.0.2", # CVE-2026-32711 (>=3.0.2); transitive via dicomweb-client/wsidicom/highdicom
+ "pyasn1>=0.6.3", # CVE-2026-30922 (>=0.6.3); transitive via cryptography
+ "lxml-html-clean>=0.4.4", # CVE-2026-28348, CVE-2026-28350 (>=0.4.4); transitive via html-sanitizer
+ "python-multipart>=0.0.26", # CVE-2026-24486 (>=0.0.22), CVE-2026-40347 (>=0.0.26); transitive via fastapi/starlette
+ "protobuf>=6.33.5", # CVE-2026-0994 (>=6.33.5); transitive via google-cloud-storage/sentry-sdk
]
[project.optional-dependencies]
@@ -148,11 +155,12 @@ jupyter = [
# WARNING: one cannot negate or downgrade a dependency required here. use override-dependencies for that.
"jupyter-core>=5.8.1", # CVE-2025-30167
"jupyterlab>=4.4.9", # CVE-2025-59842
+ "nbconvert>=7.17.1", # CVE-2025-53000 (>=7.17.0, Dependabot #424); CVE-2026-39377, CVE-2026-39378 (>=7.17.1, Dependabot #553)
]
marimo = [
"cloudpathlib>=0.23.0,<1",
"ipython>=9.8.0,<10",
- "marimo>=0.18.4,<1",
+ "marimo>=0.23.0,<1", # GHSA-2679-6mx9-h9xc (Renovate #533)
"matplotlib>=3.10.7,<4",
"shapely>=2.1.0,<3",
]
@@ -167,15 +175,16 @@ dev = [
"furo>=2025.9.25",
"git-cliff>=2.10.1,<3",
"mypy>=1.19.0,<2",
- "myst-parser>=4.0.1,<5",
+ "myst-parser>=5,<6",
"nox[uv]>=2025.11.12",
"pip-audit>=2.10.0,<3",
"pip-licenses @ git+https://github.com/neXenio/pip-licenses.git@master", # https://github.com/raimon49/pip-licenses/pull/224
"pre-commit>=4.5.0,<5",
"pyright>=1.1.408,<1.1.409", # Regression in 1.1.407, see https://github.com/microsoft/pyright/issues/11060
- "pytest>=9.0.2,<10",
+ "pytest>=9.0.3,<10", # CVE-2025-71176 requires >=9.0.3 (Renovate #538)
"pytest-asyncio>=1.3.0,<2",
"pytest-cov>=7.0.0,<8",
+ "coverage>=7.12.0,<7.13", # Pin: coverage 7.13.x introduced a regression where pytest-cov fails with "INTERNALERROR: Expected current collector to be ..." when tests spawn subprocesses (e.g., marimo server). Re-evaluate when coverage releases a fix.
"pytest-docker>=3.2.5,<4",
"pytest-durations>=1.6.1,<2",
"pytest-env>=1.2.0,<2",
@@ -189,7 +198,7 @@ dev = [
"pytest-xdist[psutil]>=3.8.0,<4",
"ruff>=0.14.8,<1",
"scalene>=2.0.1,<3",
- "sphinx>=8.2.3,<9",
+ "sphinx>=8.2.3,<9", # Tightened from <10: sphinx-toolbox 4.1.2 imports `logger` from `sphinx.ext.autodoc`, which Sphinx 9 removed. Re-evaluate when sphinx-toolbox ships Sphinx 9 support.
"sphinx-autobuild>=2025.8.25,<2026",
"sphinx-click>=6.2.0,<7",
"sphinx-copybutton>=0.5.2,<1",
@@ -200,20 +209,21 @@ dev = [
"sphinx-toolbox>=4.1.0,<5",
"sphinxcontrib.mermaid", # https://github.com/mgaitan/sphinxcontrib-mermaid
"sphinxext.opengraph>=0.9.1,<1",
- "swagger-plugin-for-sphinx>=6.1.0,<7",
+ "swagger-plugin-for-sphinx>=7,<8",
"tomli>=2.3.0,<3",
"types-pyyaml>=6.0.12.20250915,<7",
"types-requests>=2.32.4.20250913,<3",
"watchdog>=6.0.0,<7",
# Transitive overrides
# WARNING: one cannot negate or downgrade a dependency required here. use override-dependencies for that.
- "pip>=26.1", # CVE-2025-8869
- "uv>=0.9.7", # CVE-2025-54368, GHSA-w476-p2h3-79g9, GHSA-pqhf-p39g-3x64
+ "pip>=26.1", # CVE-2025-8869 (Medium, >=25.3); CVE-2026-3219 (Medium, >=26.1, released 2026-04-26 via pypa/pip#13870)
+ "uv>=0.11.6", # CVE-2025-54368, GHSA-w476-p2h3-79g9, GHSA-pqhf-p39g-3x64 (>=0.9.7); GHSA-pjjw-68hj-v9mw (>=0.11.6, Renovate #536)
"fonttools>=4.60.2", # CVE-2025-66034 (GHSA-768j-98cg-p3fv), dep of matplotlib
+ "virtualenv>=20.36.1", # pypa/virtualenv#3013 TOCTOU in app_data/lock dir; bundles filelock>=3.20.1 for CVE-2025-68146; transitive via nox/pre-commit
]
[tool.uv]
-required-version = ">=0.9.7" # CVE-2025-54368, GHSA-w476-p2h3-79g9, GHSA-pqhf-p39g-3x64
+required-version = ">=0.11.6" # CVE-2025-54368, GHSA-w476-p2h3-79g9, GHSA-pqhf-p39g-3x64, GHSA-pjjw-68hj-v9mw
# WARNING: override-dependencies is *not* respected by uvx
override-dependencies = [ # https://github.com/astral-sh/uv/issues/4422
"pytest>=9.0.1", # pytest-md-report depends on pytest<9 unnecessarily
@@ -290,6 +300,9 @@ ignore = [
]
[tool.ruff.lint.per-file-ignores]
+"**/__init__.py" = [
+ "RUF067", # __init__ modules may contain initialization logic beyond re-exports
+]
"**/tests/**/*.py" = [
# we are more relaxed in tests, while sill applying hundreds of rules
"S101", # asserts allowed in tests...
@@ -311,6 +324,7 @@ ignore = [
"ASYNC230", # async functions should not open files with blocking methods like `open`
"S104", # bind to all ports
"S607", # subprocess with partial path
+ "RUF069", # float equality comparisons in tests are intentional
]
[tool.ruff.format]
diff --git a/pyrightconfig.json b/pyrightconfig.json
index 612b268a0..b6a233da8 100644
--- a/pyrightconfig.json
+++ b/pyrightconfig.json
@@ -3,6 +3,7 @@
"exclude": [
"**/.nox/**",
"**/.venv/**",
+ "**/.claude/worktrees/**",
"**/dist-packages/**",
"**/dist_vercel/.vercel/**",
"**/dist_native/**",
diff --git a/renovate.json b/renovate.json
index 2495efd38..c0cb2b2de 100644
--- a/renovate.json
+++ b/renovate.json
@@ -22,6 +22,24 @@
"schedule": ["before 5am on monday"]
},
"packageRules": [
+ {
+ "description": "Runtime-critical packages whose bumps touch the GUI event loop, HTTP client, or pydantic models — regressions in these only surface end-to-end (closure lifecycles, event handler exception swallowing, request retry semantics, ...). Override the global labels to drop `skip:test:long_running` so the long-running e2e matrix runs and catches integration regressions on every bump. Discovered while shipping nicegui 3.10/3.11 (#531) where a silently-swallowed AssertionError inside an async click handler hung a download dialog and was only caught by the long-running suite.",
+ "matchPackageNames": [
+ "nicegui",
+ "fastapi",
+ "starlette",
+ "httpx",
+ "pydantic",
+ "pydantic-settings",
+ "uvicorn"
+ ],
+ "labels": [
+ "bot",
+ "renovate",
+ "dependencies",
+ "skip:codecov"
+ ]
+ },
{
"groupName": "minor and patch dependencies",
"matchManagers": ["pep621"],
diff --git a/requirements/SWR_SYSTEM_GUI_STATUS_PAGE_1.md b/requirements/SWR_SYSTEM_GUI_STATUS_PAGE_1.md
new file mode 100644
index 000000000..8da62c7d8
--- /dev/null
+++ b/requirements/SWR_SYSTEM_GUI_STATUS_PAGE_1.md
@@ -0,0 +1,23 @@
+---
+itemId: SWR-SYSTEM-GUI-STATUS-PAGE-1
+itemTitle: Per-Environment Betterstack Status Page in Launchpad
+itemHasParent: SHR-SYSTEM-1
+itemType: Requirement
+Requirement type: FUNCTIONAL
+Module: System
+Layer: GUI
+---
+
+As a Launchpad user, I expect the embedded Betterstack status badge and the "Check Platform Status" link to reflect only the Aignostics Platform environment my Launchpad is connected to (as configured by `AIGNOSTICS_API_ROOT`), so that I can assess the operational health of the services I actually depend on without being distracted by, or misled by, the health of unrelated environments.
+
+The Launchpad shall resolve the public Betterstack status page URL from the configured platform environment as follows:
+
+- when connected to the production environment (`AIGNOSTICS_API_ROOT` = `https://platform.aignostics.com`), the Launchpad shall embed the badge of, and link to, `https://status.platform.aignostics.com`;
+- when connected to the staging environment (`AIGNOSTICS_API_ROOT` = `https://platform-staging.aignostics.com`), the Launchpad shall embed the badge of, and link to, `https://status.platform-staging.aignostics.com`;
+- when connected to a dev or test environment, or to any other environment for which no per-environment public Betterstack status page is configured, the Launchpad shall not render the Betterstack badge in its footer and shall not render the "Check Platform Status" item in its right-side menu.
+
+The user shall be able to override the resolved status page URL through the `AIGNOSTICS_STATUS_PAGE_URL` environment variable or the equivalent constructor argument, including overriding it to an empty value to suppress the badge and link.
+
+The Launchpad shall validate any user-supplied status page URL at configuration time and reject values that are not well-formed http(s) URLs or that contain characters that could break out of an HTML attribute when rendered (`"`, `'`, `<`, `>`, backtick, backslash, whitespace), so that the Launchpad cannot be tricked into rendering attacker-controlled markup through this configuration.
+
+When the Launchpad does not render the badge or the menu link, no degraded-state placeholder is shown — both surfaces are simply omitted from the layout.
diff --git a/specifications/SPEC-APPLICATION-SERVICE.md b/specifications/SPEC-APPLICATION-SERVICE.md
index 8ea8d07de..918c7c820 100644
--- a/specifications/SPEC-APPLICATION-SERVICE.md
+++ b/specifications/SPEC-APPLICATION-SERVICE.md
@@ -343,11 +343,9 @@ class Service:
RuntimeError: When submission fails
"""
pass
+
def application_run_download(
- self,
- run_id: str,
- output_dir: Path,
- progress_callback: Optional[Callable] = None
+ self, run_id: str, output_dir: Path, progress_callback: Optional[Callable] = None
) -> DownloadProgress:
"""Download results with progress tracking
diff --git a/specifications/SPEC-BUCKET-SERVICE.md b/specifications/SPEC-BUCKET-SERVICE.md
index 7dcfd401a..5b311bf48 100644
--- a/specifications/SPEC-BUCKET-SERVICE.md
+++ b/specifications/SPEC-BUCKET-SERVICE.md
@@ -178,8 +178,9 @@ graph LR
class Service(BaseService):
"""Bucket service for S3-compatible cloud storage operations."""
- def upload(self, source_path: Path, destination_prefix: str,
- callback: Callable[[int, Path], None] | None = None) -> dict[str, list[str]]:
+ def upload(
+ self, source_path: Path, destination_prefix: str, callback: Callable[[int, Path], None] | None = None
+ ) -> dict[str, list[str]]:
"""Upload file or directory to cloud storage.
Args:
@@ -195,10 +196,13 @@ class Service(BaseService):
BotoClientError: S3 API operation failure
"""
- def download(self, what: list[str] | None = None,
- destination: Path = get_user_data_directory("bucket_downloads"),
- what_is_key: bool = False,
- progress_callback: Callable[[DownloadProgress], None] | None = None) -> DownloadResult:
+ def download(
+ self,
+ what: list[str] | None = None,
+ destination: Path = get_user_data_directory("bucket_downloads"),
+ what_is_key: bool = False,
+ progress_callback: Callable[[DownloadProgress], None] | None = None,
+ ) -> DownloadResult:
"""Download files from cloud storage with optional pattern matching.
Args:
@@ -215,8 +219,7 @@ class Service(BaseService):
BotoClientError: S3 API operation failure
"""
- def delete(self, what: list[str] | None, what_is_key: bool = False,
- dry_run: bool = True) -> int:
+ def delete(self, what: list[str] | None, what_is_key: bool = False, dry_run: bool = True) -> int:
"""Delete objects from cloud storage.
Args:
diff --git a/specifications/SPEC-LAUNCHPAD-STATUS-PAGE.md b/specifications/SPEC-LAUNCHPAD-STATUS-PAGE.md
new file mode 100644
index 000000000..1dd1a1c03
--- /dev/null
+++ b/specifications/SPEC-LAUNCHPAD-STATUS-PAGE.md
@@ -0,0 +1,194 @@
+---
+itemId: SPEC-LAUNCHPAD-STATUS-PAGE
+itemTitle: Per-Environment Betterstack Status Page in Launchpad
+itemType: Software Item Spec
+itemFulfills: SWR-SYSTEM-GUI-STATUS-PAGE-1
+itemIsRelatedTo: SPEC_GUI_SERVICE, SPEC_PLATFORM_SERVICE, SPEC_SYSTEM_SERVICE
+Module: System
+Layer: GUI / Platform Service
+Version: 1.0.0
+Date: 2026-04-26
+---
+
+## 1. Description
+
+### 1.1 Purpose
+
+This specification describes how the Aignostics Launchpad (Desktop Application, NiceGUI-based) renders the embedded Betterstack status badge in its footer and the "Check Platform Status" link in its right-side menu so that both reflect only the Aignostics Platform environment the Launchpad is currently connected to (i.e., the environment selected by `AIGNOSTICS_API_ROOT`).
+
+The motivation is that the legacy aggregate page at `https://status.aignostics.com` covers production *and* staging *and* unrelated services (Console, Portal, Career Site, Website). A user running the Launchpad against a single environment is best served by the corresponding **narrower** Betterstack property of that same environment, with no badge or link rendered when no per-environment Betterstack property exists (dev, test, or unknown environments).
+
+### 1.2 Functional Requirements
+
+The Launchpad shall:
+
+- **[FR-01]** Resolve the public Betterstack status page URL from the configured `api_root` of the platform `Settings` model.
+- **[FR-02]** Use `https://status.platform.aignostics.com` for production (`https://platform.aignostics.com`) and `https://status.platform-staging.aignostics.com` for staging (`https://platform-staging.aignostics.com`).
+- **[FR-03]** Use `None` (i.e., no public per-environment status page) for the dev environment (`https://platform-dev.aignostics.ai`) and the test environment (`https://platform-test.aignostics.ai`), and for any unknown `api_root` whose auth fields are otherwise fully provided.
+- **[FR-04]** Allow the user to override the resolved value through the `AIGNOSTICS_STATUS_PAGE_URL` environment variable or the `status_page_url` constructor argument of `Settings`. An empty string is treated as `None`.
+- **[FR-05]** Validate the resolved value at `Settings` construction time, rejecting values that are not well-formed http(s) URLs and values that contain `"`, `'`, `<`, `>`, backtick, backslash, or whitespace characters.
+- **[FR-06]** When the resolved value is non-`None`, render the Betterstack badge in the footer (as a 250×30 iframe pointing at `/badge?theme=dark`) and a "Check Platform Status" link in the right-side menu pointing at ``.
+- **[FR-07]** When the resolved value is `None`, omit the Betterstack badge from the footer and omit the "Check Platform Status" item from the right-side menu — no degraded-state placeholder is rendered.
+- **[FR-08]** Refresh the Betterstack iframe every 30 seconds (in alignment with the existing health-update interval), guarded so the refresh is a safe no-op when the iframe is absent from the DOM.
+
+### 1.3 Non-Functional Requirements
+
+- **Security**: User-controlled values must not be able to inject markup into the Launchpad webview. Defence-in-depth: (1) `Settings.status_page_url` is validated by `_validate_optional_url` before reaching the GUI layer; (2) the iframe is rendered via NiceGUI's `ui.element('iframe')` with attributes assigned through the props dict, so attribute values flow through Vue data binding rather than raw HTML construction.
+- **Backwards compatibility**: An unknown `api_root` (with all auth fields provided) must produce a safe default (`None`, no badge, no link) rather than raising an error. The aggregate `https://status.aignostics.com` page must remain unchanged and reachable for users who navigate to it directly.
+- **Resilience**: The 30-second iframe-refresh JS must remain safe when the iframe is absent from the DOM (dev/test or override-to-`None` cases). The behaviour shall not depend on the order in which the timer first fires relative to first DOM mount.
+
+### 1.4 Constraints and Limitations
+
+- The dev and test environments do not currently have a dedicated public Betterstack property; this specification deliberately treats that as a "no badge, no link" state, not an error.
+- The `Settings` `pre_init` model validator returns early when all auth fields are explicitly provided. In that path, the per-environment match block is skipped, and `status_page_url` retains its declared default (`None`) unless the caller supplied it explicitly.
+
+---
+
+## 2. Architecture and Design
+
+### 2.1 Files Touched
+
+| File | Role |
+| --- | --- |
+| `src/aignostics/platform/_constants.py` | Per-environment URL constants `STATUS_PAGE_URL_DEV`, `STATUS_PAGE_URL_TEST`, `STATUS_PAGE_URL_STAGING`, `STATUS_PAGE_URL_PRODUCTION`. |
+| `src/aignostics/platform/_settings.py` | `Settings.status_page_url: str \| None` field with `BeforeValidator(_validate_optional_url)`; resolution inside the existing `pre_init` `match...case` block alongside the auth endpoints; helper `_validate_optional_url(value: str \| None) -> str \| None`. |
+| `src/aignostics/platform/__init__.py` | Re-exports the four `STATUS_PAGE_URL_*` constants for downstream consumers. |
+| `src/aignostics/gui/_frame.py` | Reads `settings().status_page_url` once after the context manager `yield`. Conditionally renders the right-menu "Check Platform Status" item, the footer iframe, and the 30-s refresh JS based on this value. Defensive JS element guard `if (iframe) { iframe.src = iframe.src; }` so the refresh never throws when the iframe is absent. |
+| `tests/aignostics/platform/settings_test.py` | Per-environment assertions on `status_page_url` and parametrised rejection of invalid/unsafe URLs. |
+
+### 2.2 Resolution Algorithm
+
+```text
+input: api_root (string), explicit overrides (env var, constructor argument)
+output: status_page_url: str | None
+
+1. If the user provided `status_page_url` explicitly (constructor arg or
+ `AIGNOSTICS_STATUS_PAGE_URL` env var):
+ → run `_validate_optional_url`; on success use that value.
+2. Else, in the existing `pre_init` `match...case`:
+ - api_root == API_ROOT_DEV → setdefault to STATUS_PAGE_URL_DEV (None)
+ - api_root == API_ROOT_TEST → setdefault to STATUS_PAGE_URL_TEST (None)
+ - api_root == API_ROOT_STAGING → setdefault to STATUS_PAGE_URL_STAGING
+ - api_root == API_ROOT_PRODUCTION → setdefault to STATUS_PAGE_URL_PRODUCTION
+ - any other api_root with all auth fields supplied:
+ → field default applies (None)
+ - any other api_root without auth fields:
+ → ValueError UNKNOWN_ENDPOINT_URL
+```
+
+### 2.3 Validation
+
+`_validate_optional_url(value: str | None) -> str | None` is registered as a Pydantic `BeforeValidator` on the field:
+
+1. `None` → `None`.
+2. `""` → `None` (env-var loaders may produce an empty string when the variable is set but empty; treating it as `None` matches the dev/test default).
+3. Non-empty string:
+ 1. Reject if it contains any of `"`, `'`, `<`, `>`, backtick, backslash, or whitespace (RFC 3986 requires those to be percent-encoded; raw forms are either malformed or an injection attempt).
+ 2. Otherwise, delegate to the existing `_validate_url` (scheme must be `http` or `https`; netloc must be non-empty).
+
+### 2.4 Rendering
+
+In `gui/_frame.py`:
+
+```python
+status_page_url = settings().status_page_url # resolved once, reused
+
+if status_page_url:
+ # right-menu: "Check Platform Status" item with ui.link(...)
+
+if status_page_url:
+ # footer: NiceGUI iframe element, attributes via props dict (no raw HTML)
+ iframe = ui.element("iframe")
+ iframe.props["id"] = "betterstack"
+ iframe.props["src"] = urljoin(status_page_url + "/", "badge?theme=dark")
+ iframe.props["width"] = "250"
+ iframe.props["height"] = "30"
+ iframe.props["frameborder"] = "0"
+ iframe.props["scrolling"] = "no"
+ iframe.style("color-scheme: dark; margin-left: 0px;")
+
+# 30-s refresh, runs unconditionally; element existence is guarded in JS.
+ui.run_javascript(
+ "var iframe = document.getElementById('betterstack');"
+ "if (iframe) { iframe.src = iframe.src; }"
+)
+```
+
+The iframe is rendered as a NiceGUI `ui.element('iframe')` rather than `ui.html('