diff --git a/.editorconfig b/.editorconfig
new file mode 100644
index 00000000..99c54e94
--- /dev/null
+++ b/.editorconfig
@@ -0,0 +1,21 @@
+root = true
+
+[*]
+charset = utf-8
+end_of_line = lf
+insert_final_newline = true
+trim_trailing_whitespace = true
+
+[*.{py,swift}]
+indent_style = space
+indent_size = 4
+
+[*.{js,json,ts,yml,yaml}]
+indent_style = space
+indent_size = 2
+
+[Makefile]
+indent_style = tab
+
+[*.md]
+trim_trailing_whitespace = false
diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml
new file mode 100644
index 00000000..af408c96
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/bug_report.yml
@@ -0,0 +1,64 @@
+name: Bug report
+description: Report a reproducible problem in Cortex.
+title: "[Bug]: "
+body:
+ - type: markdown
+ attributes:
+ value: |
+ Thanks for helping improve Cortex. Do not include private memory, vaults, exports, credentials, or tokens.
+ Security issues belong in the private process described in SECURITY.md.
+ - type: input
+ id: version
+ attributes:
+ label: Cortex version or commit
+ placeholder: "0.2.0 build 51, or commit SHA"
+ validations:
+ required: true
+ - type: input
+ id: environment
+ attributes:
+ label: Environment
+ description: macOS version, architecture, and installation method.
+ placeholder: "macOS 15.5, Apple Silicon, downloaded DMG"
+ validations:
+ required: true
+ - type: textarea
+ id: steps
+ attributes:
+ label: Reproduction steps
+ description: Use synthetic data and the smallest deterministic reproduction.
+ placeholder: |
+ 1. Start Cortex…
+ 2. Connect…
+ 3. Observe…
+ validations:
+ required: true
+ - type: textarea
+ id: expected
+ attributes:
+ label: Expected behavior
+ validations:
+ required: true
+ - type: textarea
+ id: actual
+ attributes:
+ label: Actual behavior
+ validations:
+ required: true
+ - type: textarea
+ id: diagnostics
+ attributes:
+ label: Sanitized diagnostics
+ description: Paste only content-free logs or support-bundle excerpts you reviewed.
+ render: text
+ - type: checkboxes
+ id: checks
+ attributes:
+ label: Safety checklist
+ options:
+ - label: I searched existing issues and checked the troubleshooting guide.
+ required: true
+ - label: I removed private memory, credentials, tokens, and personal identifiers.
+ required: true
+ - label: This is not a security vulnerability.
+ required: true
diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml
new file mode 100644
index 00000000..fa019b4e
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/config.yml
@@ -0,0 +1,8 @@
+blank_issues_enabled: false
+contact_links:
+ - name: Security report
+ url: https://github.com/trace-cortex/cortex-app/security/policy
+ about: Report vulnerabilities privately; do not open a public issue.
+ - name: Support guide
+ url: https://github.com/trace-cortex/cortex-app/blob/main/SUPPORT.md
+ about: Choose the right support path and learn what diagnostics are safe to share.
diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml
new file mode 100644
index 00000000..c29c207a
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/feature_request.yml
@@ -0,0 +1,38 @@
+name: Feature request
+description: Propose a focused improvement backed by a real use case.
+title: "[Feature]: "
+body:
+ - type: textarea
+ id: problem
+ attributes:
+ label: Problem
+ description: What job is difficult today, and for whom?
+ validations:
+ required: true
+ - type: textarea
+ id: outcome
+ attributes:
+ label: Desired outcome
+ description: Describe success without prescribing an implementation.
+ validations:
+ required: true
+ - type: textarea
+ id: evidence
+ attributes:
+ label: Evidence or example
+ description: Use synthetic data; never paste private memory or credentials.
+ validations:
+ required: true
+ - type: textarea
+ id: alternatives
+ attributes:
+ label: Alternatives considered
+ - type: checkboxes
+ id: principles
+ attributes:
+ label: Product principles
+ options:
+ - label: This preserves explicit consent and local-first defaults.
+ required: true
+ - label: This does not require Cortex to produce uncited personal claims.
+ required: true
diff --git a/.github/ISSUE_TEMPLATE/installation_help.yml b/.github/ISSUE_TEMPLATE/installation_help.yml
new file mode 100644
index 00000000..50dff461
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/installation_help.yml
@@ -0,0 +1,58 @@
+name: Installation help
+description: Report a reproducible setup, launch, or first-run failure.
+title: "[Install]: "
+body:
+ - type: markdown
+ attributes:
+ value: |
+ Start with `make doctor` and the troubleshooting guide. Do not paste tokens, private memory, vault files, or account details.
+ - type: dropdown
+ id: path
+ attributes:
+ label: Installation path
+ options:
+ - Downloaded macOS DMG
+ - make demo / backend development
+ - macOS source build
+ - SDK or plugin
+ validations:
+ required: true
+ - type: input
+ id: environment
+ attributes:
+ label: Environment
+ description: OS version, architecture, and relevant Python/Node version.
+ placeholder: "macOS 15.5 arm64; Python 3.12.13"
+ validations:
+ required: true
+ - type: textarea
+ id: command
+ attributes:
+ label: Command or action that failed
+ render: shell
+ validations:
+ required: true
+ - type: textarea
+ id: output
+ attributes:
+ label: Sanitized output
+ description: Include the exact error and `make doctor` output when applicable.
+ render: text
+ validations:
+ required: true
+ - type: textarea
+ id: attempted
+ attributes:
+ label: What you already tried
+ description: Link the troubleshooting step if one applied.
+ - type: checkboxes
+ id: checks
+ attributes:
+ label: Safety and duplicate checks
+ options:
+ - label: I checked docs/TROUBLESHOOTING.md and searched existing issues.
+ required: true
+ - label: I removed tokens, private data, account details, and vault contents.
+ required: true
+ - label: This is not a security vulnerability.
+ required: true
diff --git a/.github/ISSUE_TEMPLATE/showcase.yml b/.github/ISSUE_TEMPLATE/showcase.yml
new file mode 100644
index 00000000..17c128e0
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/showcase.yml
@@ -0,0 +1,43 @@
+name: Integration showcase
+description: Share something you built with Cortex so others can learn from it.
+title: "[Showcase]: "
+body:
+ - type: markdown
+ attributes:
+ value: |
+ Thanks for building with Cortex. Share the pattern, not private data:
+ use synthetic examples and remove tokens, personal memories, and vault paths.
+ - type: input
+ id: project
+ attributes:
+ label: Project
+ description: What did you build?
+ placeholder: A coding agent that retrieves cited architecture decisions
+ validations:
+ required: true
+ - type: textarea
+ id: outcome
+ attributes:
+ label: What it unlocks
+ description: Describe the user or developer outcome in a few sentences.
+ validations:
+ required: true
+ - type: textarea
+ id: integration
+ attributes:
+ label: How Cortex is used
+ description: Which API, MCP tool, SDK, connector, or example did you start from?
+ validations:
+ required: true
+ - type: input
+ id: link
+ attributes:
+ label: Public link
+ description: Optional repository, demo, article, or screenshot URL.
+ - type: checkboxes
+ id: privacy
+ attributes:
+ label: Privacy check
+ options:
+ - label: I removed credentials, personal memories, exports, and identifying vault data.
+ required: true
diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md
new file mode 100644
index 00000000..8375d5bc
--- /dev/null
+++ b/.github/PULL_REQUEST_TEMPLATE.md
@@ -0,0 +1,34 @@
+## What changed
+
+
+
+## Why
+
+
+
+## Validation
+
+- [ ] Added or updated behavioral tests where appropriate
+- [ ] Ran `make check`
+- [ ] Ran the relevant package/app-specific checks
+- [ ] Updated current-product documentation for public behavior changes
+
+Commands and results:
+
+```text
+paste concise evidence here
+```
+
+## Risk and rollback
+
+
+
+## Privacy and security
+
+- [ ] No real memory, exports, credentials, tokens, or private identifiers are included
+- [ ] Capability and authorization checks remain enforced at execution boundaries
+- [ ] Not applicable
+
+## Screenshots
+
+
diff --git a/.github/REPOSITORY_METADATA.md b/.github/REPOSITORY_METADATA.md
new file mode 100644
index 00000000..f3eea0e9
--- /dev/null
+++ b/.github/REPOSITORY_METADATA.md
@@ -0,0 +1,42 @@
+# GitHub Repository Metadata
+
+Keep the repository's GitHub settings aligned with the public product contract.
+These fields are not derived from files, so a maintainer must update them in
+**Settings → General** when the positioning changes.
+
+## Description
+
+> Local, cited memory for Claude, Cursor, ChatGPT, and MCP agents—review what it learns and keep a readable vault you own.
+
+## Website
+
+Use the canonical product or documentation URL only when it is maintained and
+matches the current open-source release. Otherwise leave this blank rather than
+linking to an outdated landing page.
+
+## Topics
+
+```text
+ai-memory
+local-first
+mcp
+ai-agents
+rag
+personal-knowledge-management
+sqlite
+swiftui
+python
+privacy
+```
+
+## Social preview
+
+Upload [`assets/social-preview.png`](assets/social-preview.png). The same image
+appears at the top of the README, so link previews and the repository's
+two-minute visitor story stay visually consistent.
+
+## Release copy
+
+Lead with a concrete user outcome, name the supported platform, link the
+changelog, and repeat any material beta limitation. Do not use benchmark
+results as real-world quality claims.
diff --git a/.github/assets/cortex-demo.gif b/.github/assets/cortex-demo.gif
new file mode 100644
index 00000000..9d6f31fd
Binary files /dev/null and b/.github/assets/cortex-demo.gif differ
diff --git a/.github/assets/cortex-review.png b/.github/assets/cortex-review.png
new file mode 100644
index 00000000..737a5588
Binary files /dev/null and b/.github/assets/cortex-review.png differ
diff --git a/.github/assets/social-preview.png b/.github/assets/social-preview.png
new file mode 100644
index 00000000..de9a8c5d
Binary files /dev/null and b/.github/assets/social-preview.png differ
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 7fab48f0..4ab00bac 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -17,35 +17,31 @@ jobs:
python:
name: Python checks and backend tests
runs-on: ubuntu-latest
- timeout-minutes: 10
+ # The backend suite alone approaches ten minutes on some runners; this job
+ # also runs SDK, examples, and evaluation gates. Preserve failure headroom.
+ timeout-minutes: 30
steps:
- name: Check out repository
- uses: actions/checkout@v4
+ uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
- name: Set up Python
- uses: actions/setup-python@v5
+ uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
with:
python-version: "3.12"
- name: Install backend test dependencies
run: |
python -m pip install --upgrade pip
- python -m pip install -r backend/requirements.txt
- python -m pip install pytest
+ python -m pip install --require-hashes -r requirements-dev.lock
- name: Compile Python sources
run: |
python -m compileall -q \
backend \
- scripts \
- capture.py \
- github_store.py \
- ingest.py \
- instrumentation.py \
- mcp_server.py \
- redis_store.py \
- ui.py
+ examples \
+ legacy \
+ scripts
- name: Run backend unit tests
# pytest is the canonical runner: conftest.py provides fixtures + temp-dir
@@ -53,6 +49,15 @@ jobs:
# behave correctly under pytest.
run: python -m pytest backend/tests -q
+ - name: Run Python SDK tests
+ run: python -m pytest sdk/python/tests -q
+
+ - name: Run public examples against an isolated server
+ run: python scripts/examples_smoke.py
+
+ - name: Smoke-test the packaged standalone runtime
+ run: python scripts/examples_smoke.py --quickstart --runtime standalone
+
- name: Run retrieval quality regression gate
run: python scripts/retrieval_eval.py
@@ -75,14 +80,14 @@ jobs:
run: python scripts/delivery_eval.py
# EMBEDDINGS DEMO-INTEGRITY GUARD (CI-visible skip): this job installs
- # backend/requirements.txt only, which does NOT provision the model2vec package or the
+ # requirements-dev.lock, which does NOT provision the model2vec package or the
# potion-base-8M weights, so the model2vec-seeded gate legitimately skips here. We do NOT
# pass --forbid-skip (that would break CI without adding new model-provisioning
# infrastructure); instead the skip is made EXPLICIT: the eval output is captured and a
# status=skipped result emits a GitHub notice annotation, so a skip shows up in the job
- # log/summary rather than passing silently. Strict enforcement lives in the release path,
- # where the model is guaranteed present: macos/package_release.sh exports
- # CORTEX_REQUIRE_MODEL=1 and runs check_vector_runtime.py --require-model2vec.
+ # log/summary rather than passing silently. Strict enforcement lives in both the macOS
+ # release-runtime CI job below and the credentialed release path; both require the model
+ # and run check_vector_runtime.py --require-model2vec.
- name: Run model2vec retrieval-lift gate (explicit skip when model not provisioned)
run: |
python scripts/rerank_eval.py | tee "$RUNNER_TEMP/rerank_eval.json"
@@ -97,7 +102,9 @@ jobs:
run: python scripts/check_connector_baseline.py
- name: Check beta docs currency
- run: python scripts/check_docs_current.py
+ run: |
+ python scripts/check_markdown_links.py
+ python scripts/check_docs_current.py
- name: Run backend beta smoke
run: python scripts/backend_beta_smoke.py
@@ -115,10 +122,10 @@ jobs:
steps:
- name: Check out repository
- uses: actions/checkout@v4
+ uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
- name: Set up Python
- uses: actions/setup-python@v5
+ uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
with:
python-version: "3.12"
@@ -133,10 +140,18 @@ jobs:
run: bandit -c .bandit.yaml -r backend scripts -ll
- name: Audit backend dependencies for known vulnerabilities
- run: pip-audit --strict -r backend/requirements.txt
+ run: pip-audit --strict -r requirements-dev.lock
+
+ - name: Audit hosted runtime dependencies
+ run: pip-audit --strict -r backend/requirements.lock
- - name: Audit top-level dependencies for known vulnerabilities
- run: pip-audit --strict -r requirements.txt
+ - name: Audit archived prototype dependencies for known vulnerabilities
+ # rumps is correctly Darwin-marked in the manifest, so this Linux job
+ # resolves and audits the applicable archived dependency graph only.
+ run: pip-audit --strict -r legacy/requirements.txt
+
+ - name: Audit packaged backend runtime dependencies
+ run: pip-audit --strict -r backend/runtime-requirements.lock
distribution:
name: Distribution site and update manifest
@@ -145,10 +160,10 @@ jobs:
steps:
- name: Check out repository
- uses: actions/checkout@v4
+ uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
- name: Set up Python
- uses: actions/setup-python@v5
+ uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
with:
python-version: "3.12"
@@ -156,7 +171,7 @@ jobs:
run: python scripts/check_distribution_site.py
- name: Validate site update manifest
- run: python scripts/validate_update_manifest.py site/downloads/latest.json
+ run: python scripts/validate_update_manifest.py --allow-remote-artifacts site/downloads/latest.json
obsidian-plugin:
name: Obsidian plugin build
@@ -165,10 +180,10 @@ jobs:
steps:
- name: Check out repository
- uses: actions/checkout@v4
+ uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
- name: Set up Node
- uses: actions/setup-node@v4
+ uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: "22"
cache: npm
@@ -177,25 +192,97 @@ jobs:
- name: Typecheck and build Obsidian plugin
run: scripts/check_obsidian_plugin.sh
+ javascript-clients:
+ name: ${{ matrix.name }}
+ runs-on: ubuntu-latest
+ timeout-minutes: 10
+ strategy:
+ fail-fast: false
+ matrix:
+ include:
+ - name: TypeScript SDK
+ directory: sdk/typescript
+ lockfile: sdk/typescript/package-lock.json
+ release_gate: npm pack --dry-run
+ - name: OpenClaw context plugin
+ directory: packages/openclaw-cortex-context
+ lockfile: packages/openclaw-cortex-context/package-lock.json
+ release_gate: npm run release:gate
+
+ steps:
+ - name: Check out repository
+ uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
+
+ - name: Set up Python
+ uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
+ with:
+ python-version: "3.12"
+
+ - name: Set up Node
+ uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
+ with:
+ node-version: "22"
+ cache: npm
+ cache-dependency-path: ${{ matrix.lockfile }}
+
+ - name: Install dependencies
+ working-directory: ${{ matrix.directory }}
+ run: npm ci
+
+ - name: Typecheck
+ working-directory: ${{ matrix.directory }}
+ run: npm run typecheck
+
+ - name: Test
+ working-directory: ${{ matrix.directory }}
+ run: npm test
+
+ - name: Build
+ working-directory: ${{ matrix.directory }}
+ run: npm run build
+
+ - name: Validate release package
+ working-directory: ${{ matrix.directory }}
+ run: ${{ matrix.release_gate }}
+
macos-app:
- name: macOS app build
+ name: macOS release-runtime build and smoke
runs-on: macos-latest
- timeout-minutes: 15
+ timeout-minutes: 30
steps:
- name: Check out repository
- uses: actions/checkout@v4
+ uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
+
+ - name: Set up Python framework
+ uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
+ with:
+ python-version: "3.12"
- # Dev-mode build: CORTEX_BUNDLE_PYTHON=0 means no bundled interpreter and no bundled
- # model2vec model, so build.sh prints its loud "EMBEDDINGS FALLBACK" warning here — that is
- # expected and non-fatal. CORTEX_REQUIRE_MODEL is deliberately NOT set: this CI job does not
- # provision the model; only release packaging (macos/package_release.sh) enforces it.
- - name: Build app bundle
+ - name: Locate the configured Python framework
+ run: |
+ FRAMEWORK_PREFIX="$(python -c 'import sysconfig; print(sysconfig.get_config_var("PYTHONFRAMEWORKPREFIX") or "")')"
+ FRAMEWORK_SOURCE="$FRAMEWORK_PREFIX/Python.framework/Versions/3.12"
+ test -x "$FRAMEWORK_SOURCE/bin/python3.12"
+ test -f "$FRAMEWORK_SOURCE/Python"
+ echo "CORTEX_PYTHON_FRAMEWORK_SOURCE=$FRAMEWORK_SOURCE" >> "$GITHUB_ENV"
+
+ # This is release-like except for Apple credentials: CI uses ad-hoc signing,
+ # but bundles the actual interpreter, native wheels, and offline semantic model.
+ - name: Build release runtime app bundle
env:
- CORTEX_BUNDLE_PYTHON: "0"
+ CORTEX_BUNDLE_PYTHON: "1"
+ CORTEX_REQUIRE_MODEL: "1"
CORTEX_CODESIGN_IDENTITY: "-"
CORTEX_CODESIGN_TIMESTAMP: "0"
run: ./macos/build.sh
- - name: Verify ad-hoc signed app
- run: codesign --verify --deep --strict --verbose=2 macos/build/Cortex.app
+ - name: Verify release runtime and ad-hoc signature
+ run: |
+ python scripts/check_vector_runtime.py \
+ --app macos/build/Cortex.app \
+ --require-model2vec
+ python scripts/bundled_runtime_smoke.py \
+ --app macos/build/Cortex.app \
+ --startup-timeout-seconds 120
+ codesign --verify --deep --strict --verbose=2 macos/build/Cortex.app
diff --git a/.github/workflows/publish-sdks.yml b/.github/workflows/publish-sdks.yml
index de57c084..ec161c3b 100644
--- a/.github/workflows/publish-sdks.yml
+++ b/.github/workflows/publish-sdks.yml
@@ -42,10 +42,10 @@ jobs:
steps:
- name: Check out repository
- uses: actions/checkout@v4
+ uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
- name: Set up Python
- uses: actions/setup-python@v5
+ uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
with:
python-version: "3.12"
@@ -66,7 +66,7 @@ jobs:
run: python -m twine check dist/*
- name: Upload build artifacts
- uses: actions/upload-artifact@v4
+ uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: doppl-cortex-client-python-dist
path: sdk/python/dist/*
@@ -95,10 +95,10 @@ jobs:
steps:
- name: Check out repository
- uses: actions/checkout@v4
+ uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
- name: Set up Node
- uses: actions/setup-node@v4
+ uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: "20"
cache: npm
@@ -125,7 +125,7 @@ jobs:
run: npm pack --dry-run
- name: Upload build artifacts
- uses: actions/upload-artifact@v4
+ uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: doppl-tech-cortex-client-typescript-dist
path: sdk/typescript/dist/*
diff --git a/.gitignore b/.gitignore
index 76fe7f3a..30ef9430 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,9 +1,17 @@
.env
.context/
venv/
+.venv/
__pycache__/
*.pyc
+.coverage
+.pytest_cache/
+.mypy_cache/
+.ruff_cache/
+htmlcov/
+*.egg-info/
.DS_Store
+.idea/
node_modules/
npm-debug.log*
backend/data/
diff --git a/.python-version b/.python-version
new file mode 100644
index 00000000..e4fba218
--- /dev/null
+++ b/.python-version
@@ -0,0 +1 @@
+3.12
diff --git a/CHANGELOG.md b/CHANGELOG.md
new file mode 100644
index 00000000..6eef44c6
--- /dev/null
+++ b/CHANGELOG.md
@@ -0,0 +1,89 @@
+# Changelog
+
+Notable user-facing and contributor-facing changes are recorded here. Cortex is
+in beta; compatibility guarantees will tighten as the public API stabilizes.
+
+The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
+
+## [Unreleased]
+
+### Added
+
+- Reproducible Python 3.12 contributor bootstrap and root `make` commands.
+- Open-source contribution, security, support, conduct, roadmap, and issue/PR
+ guidance.
+- Runnable Python SDK examples for search, memory workflows, multi-agent
+ context, and tool discovery.
+- Benchmark documentation with deterministic regression metrics, commands, and
+ explicit evidence limitations.
+- CI gates for the Python and TypeScript SDKs and the OpenClaw context plugin.
+- A generated OpenAPI contract test and explicit public API lifecycle policy.
+- Deterministic local example data for validating the first-run search flow.
+- A one-command disposable `make demo` path and read-only `make doctor`
+ prerequisite check.
+- A packaged-standalone runtime smoke gate, connector authoring guide, and
+ contributor code map.
+- Full CMP context options in both SDKs, including SMP/Markdown projections,
+ model profiles, session deltas, pinning, project/sector scopes, and `as_of`.
+
+### Changed
+
+- Repository documentation now distinguishes implemented capabilities from
+ release-configured capabilities.
+- The archived Redis/Voyage/Streamlit prototype now lives under `legacy/`
+ instead of competing with current entry points at repository root.
+- SDK and plugin metadata points to `trace-cortex/cortex-app`.
+- Hosted deployment, Apple release, account, offline, update, and legal
+ documentation now matches the behavior in code.
+- `POST /v1/context` now returns the same identity fields as the corresponding
+ read-scoped `GET` request; exporting identity remains a separate permission.
+- Mac mini installation uses a dedicated Python 3.12 virtual environment and
+ the current public API hostname.
+- Evaluation commands print concise summaries by default and expose full
+ per-case diagnostics through `--json`.
+- Dependency-free retrieval recognizes bounded owner/DRI and
+ launch/release/rollout paraphrases and demotes explicit non-answer templates.
+- Vector-index compatibility now fingerprints provider, model/revision, native
+ dimensions, local asset identity, and embedding text recipe; same-dimension
+ model swaps rebuild and re-embed instead of mixing vector spaces.
+
+### Security
+
+- Hosted runtimes now reject local path imports, file-backed connectors, and
+ untrusted connector origins at both HTTP and storage execution boundaries.
+- Hosted capture rejects secrets and content in query strings.
+- Deployment backups are encrypted with `age`, exclude environment secrets,
+ use restrictive filesystem permissions, and fail closed without a recipient.
+- Public deployment examples default to verified email and disabled automatic
+ account verification.
+- Credential-bearing HTTP clients now refuse cross-origin redirects.
+- Hosted readiness fails closed unless connector-credential encryption is
+ enforced and an active keyring is available.
+- Password hashing concurrency and in-process rate-limiter cardinality are
+ bounded to reduce memory-exhaustion risk.
+- ZIP imports enforce an aggregate uncompressed-byte budget.
+- Vault read-modify-write operations and backup snapshots use an inter-process
+ lock, preventing silent data loss with multiple workers.
+- Synchronous exports reject corpora above a configurable preflight size
+ instead of constructing multiple unbounded in-memory copies.
+
+## [0.2.0] - 2026-07-24
+
+Release build `51` was produced from source commit
+[`0f7e33ec58264b9694f4219379d7240313d9936b`](https://github.com/trace-cortex/cortex-app/commit/0f7e33ec58264b9694f4219379d7240313d9936b)
+on `feat/connect-gate-redesign`, as recorded in the published update manifest.
+
+### Added
+
+- Developer ID signed and Apple-notarized direct-download beta.
+- Local cited memory retrieval, Review, Ask, MCP integrations, connector
+ contracts, local vault recovery, and support diagnostics.
+- Python and TypeScript SDK source packages.
+
+### Security
+
+- Scoped MCP capabilities, default redaction, audit trails, and tamper-evident
+ memory history.
+
+[Unreleased]: https://github.com/trace-cortex/cortex-app/compare/0f7e33ec58264b9694f4219379d7240313d9936b...HEAD
+[0.2.0]: https://github.com/doppl-tech/releases/releases/tag/v0.2.0-51
diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md
new file mode 100644
index 00000000..adca11ad
--- /dev/null
+++ b/CODE_OF_CONDUCT.md
@@ -0,0 +1,35 @@
+# Code of Conduct
+
+Cortex contributors are expected to make the project welcoming, rigorous, and
+safe for people with different backgrounds and levels of experience.
+
+## Expected Behavior
+
+- Be respectful, specific, and constructive.
+- Critique ideas and code, not people.
+- Assume good intent while still naming concrete risks.
+- Make room for newcomers and explain project-specific context.
+- Protect user privacy. Never post another person's memory, credentials, or
+ identifying data.
+- Accept maintainer decisions about scope without harassment or repeated
+ pressure.
+
+## Unacceptable Behavior
+
+Harassment, discrimination, threats, sexualized conduct, deliberate
+misgendering, doxxing, sustained disruption, or publishing private information
+without permission are not accepted in project spaces.
+
+## Reporting
+
+For conduct concerns, email **support@trydoppl.com** with the subject
+`Code of Conduct`. Include links or screenshots when safe to do so. Do not
+include unrelated private user data.
+
+Maintainers may edit or remove content, close discussions, restrict
+participation, or ban contributors when needed to protect the community. Reports
+will be handled as privately as the circumstances allow. Retaliation against a
+reporter or participant in a review is prohibited.
+
+This policy applies to repository discussions, pull requests, issues, community
+spaces, and public interactions where someone is representing Cortex.
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
new file mode 100644
index 00000000..a45d8449
--- /dev/null
+++ b/CONTRIBUTING.md
@@ -0,0 +1,186 @@
+# Contributing to Cortex
+
+Thank you for helping make personal AI memory more trustworthy, portable, and
+useful. This guide is the shortest path from a fresh clone to a reviewable pull
+request.
+
+## Before You Start
+
+- Use an issue for changes that alter public behavior, storage formats, security
+ boundaries, or APIs.
+- Keep pull requests focused. Separate refactors from behavior changes.
+- Never commit real memory, exports, tokens, vaults, support bundles, or user
+ identifiers.
+- Report vulnerabilities privately through [SECURITY.md](SECURITY.md).
+
+## Development Setup
+
+Requirements:
+
+- Git and Make
+- Python 3.12
+- Node.js 18+ only for TypeScript SDK work; Node.js 22 for Obsidian/OpenClaw
+- Xcode command line tools only for macOS app work
+
+```bash
+git clone https://github.com/trace-cortex/cortex-app.git
+cd cortex-app
+make doctor
+make demo
+```
+
+`make demo` runs `make setup` automatically when `.venv` is missing, then
+proves capture → retrieval → cited Ask against disposable synthetic data.
+`make setup` creates `.venv` and fails with an actionable message if the chosen
+interpreter is not Python 3.12. It installs the exact versions in
+`requirements-dev.lock` and verifies every downloaded artifact hash;
+dependency updates should refresh that file
+intentionally. Use an explicit interpreter when necessary:
+
+```bash
+make setup PYTHON=/absolute/path/to/python3.12
+```
+
+Start the local FastAPI development API:
+
+```bash
+make run
+```
+
+This uses `backend/data/Cortex.vault/`, not the installed application's
+personal vault. If port 8766 is occupied, the command prints an available port
+and the matching `CORTEX_BASE_URL` export.
+
+For the macOS app:
+
+```bash
+./macos/build.sh
+open macos/build/Cortex.app
+```
+
+The default app build is a development bundle without the release interpreter
+or embedding model. See [SETUP.md](SETUP.md) and
+[docs/APPLE_RELEASE.md](docs/APPLE_RELEASE.md) for release-like builds.
+
+## Good First Contributions
+
+You can contribute without an account or a personal Cortex vault. Useful,
+contained starting points include:
+
+- reproduce an installation failure and improve the diagnostic or
+ troubleshooting step;
+- add a synthetic import fixture and a parser regression test;
+- make one runnable example easier to understand without tying it to a model
+ provider;
+- add an adversarial retrieval or privacy case to an existing evaluation
+ harness; or
+- fix a broken, ambiguous, or stale documentation path.
+
+If your change adds a source, follow [Adding a Cortex
+Connector](docs/ADDING_A_CONNECTOR.md). For unfamiliar areas, the
+[contributor code map](docs/CODE_MAP.md) links product surfaces to their first
+implementation file and focused test.
+
+For documentation-only work, `make docs-check` is the relevant local gate.
+For code changes, open or reference an issue first when public behavior,
+storage, security, or an API contract would change. If you are unsure whether
+an idea fits, a focused feature request with a concrete use case is welcome.
+
+## What to Test
+
+Run the smallest relevant set while developing, then the pre-PR gate. The full
+backend suite currently takes roughly ten minutes on a laptop; it is not the
+first command a new contributor needs to run.
+
+```bash
+make check
+make test
+```
+
+Choose additional checks by the files you changed:
+
+```bash
+# Documentation only
+make docs-check
+
+# Connectors
+make connector-check
+
+# Packaged standalone runtime
+make runtime-check
+
+# Python SDK
+.venv/bin/python -m pytest sdk/python/tests -q
+
+# TypeScript SDK
+cd sdk/typescript && npm ci && npm run typecheck && npm test
+
+# Obsidian plugin
+scripts/check_obsidian_plugin.sh
+
+# macOS app
+./macos/build.sh
+codesign --verify --deep --strict --verbose=2 macos/build/Cortex.app
+```
+
+CI is authoritative. If a check is too expensive or platform-specific to run
+locally, say exactly what you did run in the pull request.
+
+`make check` is the required fast pre-PR gate and includes the public examples
+against both FastAPI and the packaged standalone runtime. Run `make test` for a
+broad backend change; an SDK-only change may use its focused suite plus
+`make check`.
+
+### Refreshing the Python lock
+
+`backend/requirements.txt` contains the direct runtime requirements.
+`requirements-dev.lock` freezes their complete Python 3.12 dependency graph
+plus pytest for reproducible contributor and CI installs.
+`backend/requirements.lock` freezes the hosted/API runtime, while
+`backend/runtime-requirements.lock` freezes the dependency-light runtime
+bundled into the macOS app. All three locks include PyPI artifact hashes.
+
+Regenerate both locks in an isolated temporary environment, then review every
+version and hash change:
+
+```bash
+make lock-python
+make setup
+make check
+```
+
+`make lock-python` pins its temporary lock compiler too, so generated output is
+stable across contributor machines. Do not refresh locks as an unrelated side
+effect of another change.
+
+## Engineering Expectations
+
+- Follow `.editorconfig`: UTF-8, LF endings, final newlines, four-space Python
+ and Swift indentation, and two-space JSON/YAML/TypeScript indentation.
+- Use type hints on new Python public boundaries and descriptive names over
+ abbreviations. Cortex does not yet impose a repository-wide autoformatter;
+ avoid unrelated mechanical reformatting.
+- Preserve local-first defaults and cite-or-abstain behavior.
+- Treat the vault as authoritative and SQLite/vector indexes as rebuildable.
+- Keep local and hosted HTTP surfaces behaviorally aligned.
+- Add behavioral tests for bug fixes; avoid tests that only snapshot
+ implementation details.
+- Keep capability checks at the execution boundary, not only in the UI.
+- Use temporary vaults and databases in tests.
+- Update the README or current-product docs when public behavior changes.
+- Label forward-looking designs as designs rather than shipped behavior.
+
+Architecture and trust boundaries are documented in
+[docs/ARCHITECTURE.md](docs/ARCHITECTURE.md),
+[docs/LOCAL_VAULT_FORMAT.md](docs/LOCAL_VAULT_FORMAT.md), and
+[docs/TRUST_CONTROLS.md](docs/TRUST_CONTROLS.md).
+
+## Pull Requests
+
+1. Branch from current `main`.
+2. Make one coherent change with tests and docs.
+3. Run the relevant checks.
+4. Complete the pull-request template with evidence and risk notes.
+5. Respond to review without force-pushing away useful review history.
+
+By participating, you agree to follow [CODE_OF_CONDUCT.md](CODE_OF_CONDUCT.md).
diff --git a/Makefile b/Makefile
new file mode 100644
index 00000000..839440d3
--- /dev/null
+++ b/Makefile
@@ -0,0 +1,111 @@
+PYTHON ?= python3.12
+VENV ?= .venv
+VENV_PYTHON := $(VENV)/bin/python
+
+.PHONY: help doctor setup demo run run-standalone test check docs-check connector-check examples-check runtime-check lock-python clean
+
+help:
+ @printf '%s\n' \
+ 'Cortex contributor commands:' \
+ ' make doctor Check local prerequisites without changing anything' \
+ ' make setup Create .venv with Python 3.12 and install test dependencies' \
+ ' make demo Set up if needed, then run an isolated cited-answer demo' \
+ ' make run Start the local FastAPI dev server (suggests a free port on conflict)' \
+ ' make run-standalone Start the stdlib engine shipped inside the macOS app' \
+ ' make test Run the backend test suite' \
+ ' make check Run the fast local pre-PR checks' \
+ ' make docs-check Validate documentation, site links, and release metadata' \
+ ' make connector-check Validate all 13 connector contracts' \
+ ' make examples-check Run every public Python example against an isolated server' \
+ ' make runtime-check Smoke-test the standalone runtime shipped in the macOS app' \
+ ' make lock-python Regenerate hash-locked Python dependency graphs' \
+ ' make clean Remove local Python caches (keeps .venv)'
+
+doctor:
+ @printf '%s\n' 'Cortex backend prerequisites:'
+ @command -v git >/dev/null 2>&1 \
+ && printf '%s\n' ' [ok] git' \
+ || { printf '%s\n' ' [missing] git — install Xcode Command Line Tools or Git' >&2; exit 1; }
+ @PYTHON_BIN="$(PYTHON)" ./scripts/bootstrap_dev.sh --check
+ @printf '%s\n' ' [optional] Node.js 18+ is needed for the TypeScript SDK; Node.js 22 for plugins'
+ @printf '%s\n' ' [optional] Xcode Command Line Tools are needed only for the macOS app'
+ @printf '%s\n' 'Ready for: make demo'
+
+setup:
+ PYTHON_BIN="$(PYTHON)" CORTEX_VENV="$(abspath $(VENV))" ./scripts/bootstrap_dev.sh
+
+demo:
+ @if ! test -x "$(VENV_PYTHON)" \
+ || ! "$(VENV_PYTHON)" -c 'import sys, fastapi, uvicorn, cortex_client; raise SystemExit(sys.version_info < (3, 12))' >/dev/null 2>&1; then \
+ printf '%s\n' 'Cortex dependencies are missing or incomplete; running make setup first...'; \
+ $(MAKE) setup PYTHON="$(PYTHON)" VENV="$(VENV)"; \
+ fi
+ "$(VENV_PYTHON)" scripts/examples_smoke.py --quickstart
+
+run:
+ @test -x "$(VENV_PYTHON)" || { echo "Missing $(VENV_PYTHON). Run 'make setup' first." >&2; exit 1; }
+ CORTEX_PYTHON="$(abspath $(VENV_PYTHON))" ./scripts/dev_backend.sh
+
+run-standalone:
+ @test -x "$(VENV_PYTHON)" || { echo "Missing $(VENV_PYTHON). Run 'make setup' first." >&2; exit 1; }
+ cd backend && \
+ CORTEX_API_KEY="$${CORTEX_API_KEY:-dev-local-key}" \
+ CORTEX_ALLOW_INSECURE_DEV_TOKEN="$${CORTEX_ALLOW_INSECURE_DEV_TOKEN:-1}" \
+ CORTEX_VAULT_PATH="$${CORTEX_VAULT_PATH:-./data/Cortex.vault}" \
+ CORTEX_DB_PATH="$${CORTEX_DB_PATH:-./data/Cortex.vault/index.sqlite}" \
+ "$(abspath $(VENV_PYTHON))" -m app.standalone_server \
+ --host "$${CORTEX_HOST:-127.0.0.1}" \
+ --port "$${CORTEX_PORT:-8766}"
+
+test:
+ @test -x "$(VENV_PYTHON)" || { echo "Missing $(VENV_PYTHON). Run 'make setup' first." >&2; exit 1; }
+ "$(VENV_PYTHON)" -m pytest backend/tests -q
+
+docs-check:
+ @test -x "$(VENV_PYTHON)" || { echo "Missing $(VENV_PYTHON). Run 'make setup' first." >&2; exit 1; }
+ "$(VENV_PYTHON)" scripts/check_markdown_links.py
+ "$(VENV_PYTHON)" scripts/check_docs_current.py
+ "$(VENV_PYTHON)" scripts/check_distribution_site.py
+ "$(VENV_PYTHON)" scripts/validate_update_manifest.py --allow-remote-artifacts site/downloads/latest.json
+
+connector-check:
+ @test -x "$(VENV_PYTHON)" || { echo "Missing $(VENV_PYTHON). Run 'make setup' first." >&2; exit 1; }
+ "$(VENV_PYTHON)" scripts/check_connector_baseline.py
+
+examples-check:
+ @test -x "$(VENV_PYTHON)" || { echo "Missing $(VENV_PYTHON). Run 'make setup' first." >&2; exit 1; }
+ "$(VENV_PYTHON)" scripts/examples_smoke.py
+
+runtime-check:
+ @test -x "$(VENV_PYTHON)" || { echo "Missing $(VENV_PYTHON). Run 'make setup' first." >&2; exit 1; }
+ "$(VENV_PYTHON)" scripts/examples_smoke.py --quickstart --runtime standalone
+
+check: docs-check
+ "$(VENV_PYTHON)" scripts/retrieval_eval.py
+ "$(VENV_PYTHON)" scripts/adaptation_eval.py
+ "$(VENV_PYTHON)" -m pytest sdk/python/tests -q
+ "$(VENV_PYTHON)" scripts/examples_smoke.py
+ "$(VENV_PYTHON)" scripts/examples_smoke.py --quickstart --runtime standalone
+
+lock-python:
+ @"$(PYTHON)" -c 'import sys; raise SystemExit(sys.version_info[:2] != (3, 12))' \
+ || { printf '%s\n' 'make lock-python requires Python 3.12 (override PYTHON=/path/to/python3.12).' >&2; exit 1; }
+ @LOCK_TMP="$$(mktemp -d "$${TMPDIR:-/tmp}/cortex-lock.XXXXXX")"; \
+ trap 'rm -r -- "$$LOCK_TMP"' EXIT; \
+ "$(PYTHON)" -m venv "$$LOCK_TMP/venv"; \
+ "$$LOCK_TMP/venv/bin/python" -m pip install --disable-pip-version-check --quiet \
+ 'pip==25.3' 'pip-tools==7.5.2'; \
+ CUSTOM_COMPILE_COMMAND='make lock-python' "$$LOCK_TMP/venv/bin/pip-compile" \
+ --quiet --generate-hashes --resolver=backtracking --strip-extras \
+ --output-file=requirements-dev.lock requirements-dev.in; \
+ CUSTOM_COMPILE_COMMAND='make lock-python' "$$LOCK_TMP/venv/bin/pip-compile" \
+ --quiet --generate-hashes --resolver=backtracking --strip-extras \
+ --output-file=backend/requirements.lock backend/requirements.txt; \
+ CUSTOM_COMPILE_COMMAND='make lock-python' "$$LOCK_TMP/venv/bin/pip-compile" \
+ --quiet --generate-hashes --resolver=backtracking --strip-extras \
+ --output-file=backend/runtime-requirements.lock backend/runtime-requirements.txt; \
+ printf '%s\n' 'Updated contributor, hosted, and bundled-runtime Python locks'
+
+clean:
+ find backend scripts sdk -type d -name __pycache__ -prune -exec rm -r {} +
+ find backend scripts sdk -type f -name '*.pyc' -delete
diff --git a/README.md b/README.md
index e3081e23..5dfad45e 100644
--- a/README.md
+++ b/README.md
@@ -1,78 +1,198 @@
-
+
# Cortex
-### Your personal operating model for AI: a local, cited model of how you work.
+### The local memory layer for the AI tools you already use.
-Every AI tool runs on a foundation model of the world. Cortex compiles a **model of you** and serves
-it to them. It turns your notes and AI-chat history into a typed, layered, **cited** model of your
-voice, preferences, decisions, and the graph of your world, then feeds it to Claude, ChatGPT, Cursor,
-and any MCP client. Your tools stop starting from zero and start reasoning with your judgment loaded
-in, on hardware you own.
+**Review what it learns · Retrieve with citations · Keep a vault you own**
-
+[](https://github.com/trace-cortex/cortex-app/actions/workflows/ci.yml)
+[](https://github.com/doppl-tech/releases/releases/latest)
+[](LICENSE)
+[](.python-version)
-
-[](https://uwaterloo.ca)
-[](https://uwaterloo.ca/engineering)
-[](https://uwaterloo.ca/research)
-[](https://composio.dev)
-[](LICENSE)
+
-[](https://github.com/doppl-tech/releases/releases/latest)
+
-
+Cortex gives Claude, Cursor, ChatGPT, and other AI tools one user-owned memory.
+Bring in notes and chat history, approve what is worth remembering, then
+retrieve only the context a task needs over MCP or HTTP. The local engine
+returns source citations—or explicitly abstains when the evidence is not there.
-
-
-
-
-
-
-
-
+> If local, portable AI memory is a problem you want solved, **star Cortex to
+> follow the beta**. If something breaks, tell us—the project is built in the
+> open and the rough edges are documented.
-
+## Why Cortex exists
----
-
-## How the operating model works
-
-Approved memory goes in; a calibrated model of how you operate comes out. Five systems make that real:
-
-- **It dreams.** A bounded **sleep-time consolidation pass** runs while you are away: it resolves only
- contradictions that clear deterministic safety rules (your source memory stays authoritative and
- every decision is logged), and pre-warms **verified hot-context packs** so the next agent request is
- served from a checked cache, not a cold build.
-- **It recalls associatively.** Retrieval walks a trust-aware **knowledge graph** with bounded
- multi-hop recall, including a **personalized-PageRank** mode that surfaces what is *connected* to the
- query, not just lexically near it. Hops are budgeted and carry provenance.
-- **It packs context as a protocol.** The [**Contextual Memory Protocol**](docs/CMP_PROTOCOL.md) fits a
- model-calibrated **SMP envelope** with a token-aware knapsack, then runs a **per-session delta
- channel** that never re-sends what an agent already holds (enforced invariants, measured savings).
- Retrieval fuses BM25, `sqlite-vec` KNN, temporal, and intent, reranks on-device, and **cites or
- abstains**.
-- **It models judgment.** Deterministic extractors and an LLM condenser build seven typed layers
- (voice, preferences, decisions, facts, episodic, entities, topics) into a **whole-person map**.
- Agents call `GET /v1/agent-adaptation` to load your calibration brief before they work. That is the
- Doppl thesis: a working model of how you operate, so delegation stops meaning re-explanation.
-- **It compounds as an asset you own.** Packs are **sha256-addressed and replayable**. The model lives
- as plain Markdown plus a rebuildable index on your Mac, vendor-portable, inspectable, and erasable in
- one act. Switch assistants and your model comes with you.
-
-## The loop
+| Stop repeating yourself | Keep memory trustworthy | Switch tools freely | Inspect everything |
+|---|---|---|---|
+| Reuse approved project decisions, preferences, and context across sessions. | New memories wait in Review; edit, approve, or archive them. | Serve the same cited memory to MCP clients instead of rebuilding a profile in every app. | The vault stays readable as Markdown/JSON, with SQLite as a rebuildable index. |
-
+## One-command local demo
+
+The demo uses three synthetic Project Atlas records. The source workflow needs
+Git, Make, and Python 3.12, but no API key, Docker, Redis, model provider, or
+personal Cortex vault.
+
+```bash
+git clone https://github.com/trace-cortex/cortex-app.git
+cd cortex-app
+make demo
+```
+
+
+
+
+
+`make demo` checks prerequisites, creates `.venv` when needed, runs an isolated
+loopback server on a free port, prints a cited answer, then removes the
+temporary vault. If Python 3.12 is not discoverable, `make doctor` prints the
+exact fix for an existing interpreter. A fresh clone downloads the pinned
+development dependencies and may take a few minutes; subsequent runs complete
+in seconds.
+
+
+See the exact expected output
+
+```text
+Cortex is working.
+ [ok] isolated loopback server started
+ [ok] 3 synthetic Project Atlas captures loaded
+ [ok] Ask returned cited evidence
+
+Question: When does Project Atlas ship?
+Answer: Cortex returned a cited answer for this question: …
+Citation: https://example.invalid/cortex-demo/project-atlas
+```
+
+
+
+### Pick your path
+
+| I want to… | Fastest path |
+|---|---|
+| Use Cortex | **[Download the signed and notarized macOS beta](https://github.com/doppl-tech/releases/releases/latest)** |
+| Evaluate the engine safely | Run `make demo` with synthetic data |
+| Build an agent integration | Use the [Python SDK](sdk/python/README.md), [TypeScript SDK](sdk/typescript/README.md), or [MCP guide](docs/MCP_INTEGRATIONS.md) |
+| Contribute | Run `make doctor`, then open the [contributor guide](CONTRIBUTING.md) |
+| Understand the internals | Read [Architecture](docs/ARCHITECTURE.md), [CMP](docs/CMP_PROTOCOL.md), and the [vault format](docs/LOCAL_VAULT_FORMAT.md) |
+
+> [!NOTE]
+> The desktop beta currently requires Apple Silicon, macOS 13+, and a one-time
+> account sign-in. Backend development and the demo run on macOS or Linux.
+> Read the [limitations](#current-limitations-plainly) before installing.
+
+The source of truth is this `trace-cortex` repository. Signed installers are
+published by the project maintainer in `doppl-tech/releases`; the committed
+[release manifest](site/downloads/latest.json) records the build, source
+commit, artifacts, and checksums.
+
+## The product loop
| ① Connect | ② Review | ③ Ask | ④ Control |
-|:--:|:--:|:--:|:--:|
-| Bring in notes, an AI-chat export, or sign in and import your history | Approve what's useful, archive the noise — memory stays trustworthy | Ask with cited answers, or let a connected AI tool retrieve what you approved | Keep reads, saves, exports, and every connection visible and revocable |
+|---|---|---|---|
+| Bring in notes, chat exports, files, or a live source. | Approve what is useful; edit or archive the noise. | Get cited evidence in Cortex or retrieve it from an MCP client. | See and revoke every tool, scope, read, save, and export permission. |
-
+
+
+
Published macOS beta · Review screen · synthetic sample notes, not personal data
+
+## A different default for AI memory
+
+| Design choice | Cortex default | Why it matters |
+|---|---|---|
+| Storage | Local readable vault; rebuildable index | Your memory survives an app or model change |
+| Admission | Explicit review for connected sources | Noisy inputs do not silently become personal facts |
+| Retrieval | Cited evidence or abstention | Agents can preserve provenance instead of inventing confidence |
+| Delivery | Bounded, model-calibrated context over MCP and HTTP | Tools receive task-relevant context, not a vault dump |
+| Providers | On-device embeddings; deterministic Ask | The core loop does not need a model API key |
+
+## Reproducible proof, not a leaderboard claim
+
+The checked-in offline gates use synthetic fixtures with known answers and no
+model API calls. The recorded source-checkout run passed 163 labeled
+**hash/FTS retrieval** queries, 30 context-packing checks, and every no-leak and
+budget check. It does not measure the bundled Model2Vec semantic path.
+
+| Gate | Recorded result |
+|---|---:|
+| Deterministic hash/FTS retrieval top-1 / recall@3 | 1.000 / 1.000 |
+| Packed-context citation coverage | 1.000 |
+| Session delta token savings | 28.5% |
+| Token-estimator MAPE vs simulated references | 1.58% |
+
+These are deterministic regression results—not proof of real-world answer
+quality or latency. Read the [method, machine-readable snapshot, commands, and
+limitations](docs/BENCHMARKS.md), then reproduce them locally. CI also runs
+2,000+ backend tests plus SDK, plugin, security, packaging, and documentation
+gates.
+
+## What you can build
+
+| Starting point | Outcome |
+|---|---|
+| [`minimal_search.py`](examples/minimal_search.py) | Add cited local-memory search to a script in a few lines |
+| [`memory_workflow.py`](examples/memory_workflow.py) | Search and Ask a factual query, then assemble bounded context for a work-shaped task |
+| [`multi_agent_context.py`](examples/multi_agent_context.py) | Give planner, researcher, and writer agents different views of the same approved memory |
+| [`tool_catalog.py`](examples/tool_catalog.py) | Expose Cortex through an OpenAI-compatible tool catalog without calling a model |
+| [Connector guide](docs/ADDING_A_CONNECTOR.md) | Send records through the generic source contract or ship a first-party connector |
+
+All examples are provider-neutral and run against the same local API. Start
+with the [example guide and synthetic fixture](examples/README.md).
+
+## What Cortex does today
+
+Cortex is a **macOS beta**. It runs a local engine on your Mac that ingests your notes and AI-chat
+exports, turns them into typed and cited memory you review, and serves that memory to AI tools over
+MCP.
+
+Concretely, today you can:
+
+- **Bring in your history.** Drop in a ChatGPT, Claude, Gemini, Notion, Slack, Discord, or Telegram
+ export — or mbox/eml, `.ics`, `.vcf`, DOCX, Zoom transcripts, browser bookmarks, an X or LinkedIn
+ archive, and more. See [`docs/SOURCE_IMPORTS.md`](docs/SOURCE_IMPORTS.md).
+- **Sync live sources with a key you paste.** GitHub (secretless device-flow sign-in), Slack, Linear,
+ Jira, Readwise, Raindrop, Zotero, Calendar, Notion, and Obsidian.
+- **Review before you remember.** Captures from connected sources land in a Review inbox, grouped into at
+ most fifteen decisions, where you approve, edit, or archive. Approving records the memory and its audit
+ event in SQLite, then mirrors it to your Markdown vault. Files you import yourself are treated as trusted
+ and are usable right away; pass `auto_approve=false` to route them through Review too.
+- **Ask and get citations or nothing.** Retrieval fuses BM25, `sqlite-vec` vector KNN, and temporal
+ signals, reranks on-device, and passes through a cite-or-abstain gate. With no cited evidence it tells
+ you so rather than guessing.
+- **Connect your tools in one click.** Claude Desktop, Cursor, Windsurf, Zed, Cline, Roo Code, VS Code
+ Copilot, and Claude Code get a Cortex MCP server written into their config.
+- **Keep control.** Per-tool tokens are scoped and revocable, secret/email redaction is on by default,
+ and export, maintenance, and destructive capabilities are **off** until you enable them.
+
+Ask composes its answers deterministically from cited excerpts — no generative language model is
+bundled or called to write prose. Retrieval embeddings run entirely on-device.
+
+### Current limitations, plainly
+
+- **Apple Silicon, macOS 13+.** The Swift target is `arm64-apple-macosx13.0`; there is no Intel or
+ universal build.
+- **This beta requires a one-time account sign-in.** The shipped build sets `CortexRequireAccount`, so
+ first launch shows a sign-in wall. Your memory still lives and is queried locally — the account is
+ identity plus an optional sync target. There is an "Explore with sample notes" path, but it is not
+ remembered between launches.
+- **Cloud sync is not end-to-end encrypted by default.** Client-side E2EE exists but is opt-in and off,
+ so anything you sync is readable server-side. See [`docs/E2EE_SYNC_DESIGN.md`](docs/E2EE_SYNC_DESIGN.md).
+- **Managed Google / Microsoft / Notion OAuth is implemented but not configured** in this build; those
+ client IDs ship empty. Use file import or a pasted token instead.
+- **PDF text extraction is inactive** in the shipped app — `pypdf` is not bundled.
## How it works
@@ -80,7 +200,7 @@ Approved memory goes in; a calibrated model of how you operate comes out. Five s
flowchart LR
subgraph SOURCES["Your sources"]
A1["Local notes / Obsidian"]
- A2["ChatGPT · Claude · Perplexity · Notion"]
+ A2["ChatGPT · Claude · Notion · Slack"]
A3["Files & exports"]
end
subgraph CORTEX["Cortex: your operating model, on your Mac"]
@@ -90,70 +210,243 @@ flowchart LR
B3["Context Assembly Engine (CMP)"]
end
subgraph TOOLS["Your AI tools"]
- C1["Claude Desktop · Cursor Windsurf · Zed · any MCP client"]
+ C1["Claude Desktop · Cursor Windsurf · Zed · Cline · more"]
end
SOURCES --> B1 --> B2 --> B4 --> B3 --> C1
C1 -. "cited retrieval" .-> B3
```
-A native **SwiftUI** app bundles a local **FastAPI** engine on `127.0.0.1:8766`. Ingested sources become
-typed, layered memory in a **SQLite** store (full-text + `sqlite-vec` vectors) that mirrors to a
-human-readable, Obsidian-style vault you own. When a tool asks, the **Contextual Memory Protocol** packs
-the smallest cited, model-calibrated context that answers the task.
+A native **SwiftUI** app bundles a Python 3.12 interpreter and launches a local engine
+(`backend/app/standalone_server.py`, stdlib `http.server`) on `127.0.0.1:8766`, loopback only. Ingested
+sources become typed memory in a **SQLite** store — FTS5 full-text plus `sqlite-vec` vectors — that
+mirrors to a human-readable, Obsidian-style vault at
+`~/Library/Application Support/Cortex/Cortex.vault/`. When a tool asks, the **Contextual Memory
+Protocol** packs the smallest cited, model-calibrated context that answers the task.
+
+The same storage core also runs behind a **FastAPI** app (`backend/app/main.py`) for the hosted
+accounts and sync plane. That server is not part of the desktop app's local engine.
+
+## Inside the operating model
+
+Approved memory goes in; a calibrated model of how you operate comes out.
+
+- **It packs context as a protocol.** The [Contextual Memory Protocol](docs/CMP_PROTOCOL.md) fits an
+ **SMP envelope** calibrated to the consuming model: name your model and the pack is token-budgeted and
+ filled by a marginal-utility knapsack with MMR diversity rather than a flat greedy fill. A
+ **per-session delta channel** then tells each agent what changed since its last turn — new,
+ superseded, evicted — and never re-announces a memory it already holds, an invariant pinned in CI by
+ [`scripts/context_pack_eval.py`](scripts/context_pack_eval.py).
+- **It retrieves and cites, or abstains.** BM25 and `sqlite-vec` vector KNN are fused with temporal
+ signals by weighted reciprocal rank, diversified by provenance, and reranked on-device with MMR; an
+ intent retriever backs them up when the lexical and temporal arms come back empty. Embeddings come
+ from a bundled 256-dimension `model2vec` model — no API key, no network. When nothing carries a
+ relevant citation, Ask returns that fact instead of an answer.
+- **It models judgment.** Deterministic extractors build seven typed layers — `semantic`, `episodic`,
+ `style`, `decision`, `preference`, `negative`, `procedural` — plus entity and topic indexes, into a
+ whole-person map. Agents call `GET /v1/agent-adaptation` to load your calibration brief before they
+ work.
+- **It recalls relationally.** Ask expands along direct stored relations (shared entity, same capture).
+ Agents can opt into **bounded multi-hop recall** over a trust-aware memory graph — depth ≤ 3,
+ trust-floored, path-strength gated — including a true **personalized-PageRank** mode, via
+ `search_memory` with `associative=true`. Every hop returns its full path with per-edge kind and weight.
+ Neither is the default Ask path.
+- **It consolidates on request.** A bounded consolidation pass resolves only contradictions that clear
+ deterministic safety rules, keeps your source memory authoritative, logs every decision, and pre-warms
+ verified hot-context packs. It runs when invoked (`POST /v1/memory/consolidate` or the
+ `consolidate_memory` tool) and requires the maintenance permission, which is off by default.
+- **It compounds as an asset you own.** Pin a pack and it becomes **sha256-addressed and replayable**,
+ byte-verified on every read, so an agent can prove exactly what it acted on. Your model lives as plain
+ Markdown memories plus durable JSON/JSONL records, with an index rebuildable from that vault — vendor-portable, inspectable, exportable as
+ a signed bundle, and erasable in one act. Switch assistants and it comes with you.
## Also inside
-- **One-click connections.** Write-and-relaunch MCP config for desktop tools, session-import for the
- web chat apps, drag-and-drop for exports. See [`docs/MCP_INTEGRATIONS.md`](docs/MCP_INTEGRATIONS.md).
-- **Trust controls.** Scoped, revocable per-tool permissions with redaction on by default. See [`docs/TRUST_CONTROLS.md`](docs/TRUST_CONTROLS.md).
-- **Optional cloud sync.** An end-to-end-encryption design for multi-device sync, opt-in and account-based; data stays local unless you turn it on. See [`docs/ACCOUNTS_ENCRYPTION_DESIGN.md`](docs/ACCOUNTS_ENCRYPTION_DESIGN.md).
+- **One-click connections.** Config-write-and-relaunch for desktop MCP clients, deeplink install for
+ Cursor and VS Code, and copy-paste HTTP endpoints for LM Studio, Open WebUI, LibreChat, and
+ AnythingLLM. Web tools (ChatGPT, Claude web, Perplexity, Gemini) connect through a hosted connector
+ key and need an account. See [`docs/EXTERNAL_INTEGRATIONS.md`](docs/EXTERNAL_INTEGRATIONS.md) and
+ [`docs/MCP_INTEGRATIONS.md`](docs/MCP_INTEGRATIONS.md).
+- **A curated MCP surface.** Tokens minted for your tools carry `read` and `write` scopes and see ten
+ core tools, including `use_cortex`, `get_context`, `ask_memory`, `search_memory`, and `remember_this`.
+ Permission checks run on every call, not just in the UI.
+- **Trust controls.** Scoped revocable per-tool permissions, redaction on by default, an append-only
+ audit log, and a tamper-evident hash chain over your history. See
+ [`docs/TRUST_CONTROLS.md`](docs/TRUST_CONTROLS.md).
+- **Portable memory.** A published, test-vectored bundle spec under
+ [`spec/portable-memory/v2/`](spec/portable-memory/v2). See
+ [`docs/PORTABLE_MEMORY_PROTOCOL_V2.md`](docs/PORTABLE_MEMORY_PROTOCOL_V2.md).
+
+## Beyond the Mac app
+
+| Surface | What it is | Path |
+|---|---|---|
+| Obsidian plugin | Sync a vault and write cited memory back into it | [`packages/obsidian-cortex-plugin`](packages/obsidian-cortex-plugin) |
+| Browser extension | MV3 extension that injects cited context into ChatGPT, Claude, and Notion | [`extension/`](extension) |
+| OpenClaw context plugin | Context-engine adapter for OpenClaw | [`packages/openclaw-cortex-context`](packages/openclaw-cortex-context) |
+| Python SDK | Dependency-free client for the local API | [`sdk/python`](sdk/python) |
+| TypeScript SDK | Dependency-free client for the local API | [`sdk/typescript`](sdk/typescript) |
## Install
-1. **[Download the latest DMG →](https://github.com/doppl-tech/releases/releases/latest)** (macOS 13 or later).
+1. **[Download the latest DMG →](https://github.com/doppl-tech/releases/releases/latest)** (macOS 13 or
+ later, Apple Silicon).
2. Open the DMG and drag **Cortex** into Applications.
-3. Launch it. It's Developer ID signed and **notarized by Apple**, so it opens with no warning.
-4. Point Cortex at a notes folder or import your AI chats, review your first memories, then connect a tool.
+3. Launch it. Release builds are Developer ID signed and **notarized by Apple**, so it opens with no
+ warning.
+4. Sign in, then point Cortex at a notes folder or import your AI chats, review your first memories,
+ and connect a tool.
+
+Cortex checks the HTTPS release feed for new builds and links you to the current DMG. Installing an
+update still uses the normal macOS app-replacement flow.
+
+## Run a persistent development server
+
+Use `make demo` first if you only want to evaluate the engine. It creates the
+development environment, so you do not need to run setup again. If you skipped
+the demo, run `make setup` once.
+
+For API and SDK development, start the FastAPI development runtime locally:
-Cortex checks for updates on its own, so once you're on a recent build, new releases arrive automatically.
+```bash
+CORTEX_AUTO_APPROVE_CAPTURES=1 make run
+```
+
+This is local development—not Cortex Cloud. It writes disposable development
+state under `backend/data/Cortex.vault/` and does not read the installed app's
+vault under `~/Library/Application Support/Cortex/`. Auto-approval is enabled
+here so the synthetic examples are immediately searchable; normal product
+sources remain review-first.
+
+If the installed app or another process already owns port 8766, `make run`
+prints a free-port command and the matching `CORTEX_BASE_URL` export.
+
+In a second terminal:
+
+```bash
+curl -sS http://127.0.0.1:8766/v1/captures \
+ -H 'Authorization: Bearer dev-local-key' \
+ -H 'Content-Type: application/json' \
+ -d '{"content":"Project Atlas ships Thursday after the rollback drill.","source":"quickstart"}'
+
+curl -sS -G http://127.0.0.1:8766/v1/ask \
+ -H 'Authorization: Bearer dev-local-key' \
+ --data-urlencode 'query=When does Project Atlas ship?'
+```
+
+The second response either includes the cited quickstart memory or explicitly
+abstains. Next, try the runnable [Python examples](examples/README.md), the
+[Python SDK](sdk/python/README.md), or the
+[TypeScript SDK](sdk/typescript/README.md). Interactive API documentation is at
+`http://127.0.0.1:8766/docs`. `dev-local-key` is accepted only by the explicit
+local development configuration.
## Build from source
+Backend/API development requires **Git, Make, and Python 3.12**. Xcode command
+line tools are required only to build the macOS app. CI and the packaged
+runtime both use Python 3.12.
+
```bash
-# macOS app (SwiftUI)
+# Reproducible contributor environment
+make setup
+make check
+make test
+
+# macOS app (SwiftUI) — writes macos/build/Cortex.app
./macos/build.sh
-# Backend engine + test suite (Python 3.11+)
-python3 -m venv .venv && source .venv/bin/activate
-pip install -r backend/runtime-requirements.txt
-python3 -m pytest backend/tests
+# Run the local FastAPI development API at 127.0.0.1:8766
+make run
-# Retrieval-quality gate (deterministic, offline)
-python3 scripts/retrieval_eval.py
+# Or run the stdlib engine the packaged app actually ships
+make run-standalone
+
+# Deterministic, offline quality gates (use the pinned contributor environment)
+.venv/bin/python scripts/retrieval_eval.py
+.venv/bin/python scripts/adaptation_eval.py
```
-See [`SETUP.md`](SETUP.md) for the full development setup and [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md)
-for how the pieces fit together.
+By default `./macos/build.sh` produces an ad-hoc-signed app with **no** bundled interpreter or
+embedding model, and prints an embeddings-fallback warning — good enough to launch and inspect. A full
+build needs `CORTEX_BUNDLE_PYTHON=1` and a python.org framework install at
+`/Library/Frameworks/Python.framework/Versions/3.12`, or set
+`CORTEX_PYTHON_FRAMEWORK_SOURCE` to another complete Python 3.12 framework
+(including Homebrew's framework). Release packaging lives in
+`macos/package_release.sh`; CI boots and exercises the bundled interpreter,
+native vector runtime, offline embedding model, capture path, and cited Ask.
+
+CI runs the full gate set for pull requests and pushes to `main` — see [`.github/workflows/ci.yml`](.github/workflows/ci.yml)
+for the authoritative command list. [`SETUP.md`](SETUP.md) covers the app-side development loop.
+
+## Repository layout
+
+| Path | What lives there |
+|---|---|
+| `macos/` | The SwiftUI/AppKit app and its build and packaging scripts |
+| `backend/app/` | Storage core, retrieval, connectors, MCP tools, and both HTTP servers |
+| `backend/tests/` | The test suite (2,000+ pytest cases, plus parameterized subtests) |
+| `scripts/` | Dev helpers plus the deterministic CI quality gates |
+| `packages/`, `sdk/`, `extension/` | Plugins, client SDKs, and the browser extension |
+| `docs/` | Architecture, protocol, and release documentation |
+| `deploy/`, `site/` | Hosted-plane ops and the static distribution site |
+| `legacy/` | Archived Redis/Voyage/Streamlit prototype; not used by or installed with the current product |
+
+Use the [contributor code map](docs/CODE_MAP.md) to find the first implementation
+file and focused test for a change.
## Documentation
| Area | Doc |
|---|---|
-| System architecture | [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) |
+| Documentation index and status | [`docs/README.md`](docs/README.md) |
+| System architecture and contributor code map | [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) · [`docs/CODE_MAP.md`](docs/CODE_MAP.md) |
| Contextual Memory Protocol | [`docs/CMP_PROTOCOL.md`](docs/CMP_PROTOCOL.md) · [`docs/PORTABLE_MEMORY_PROTOCOL_V2.md`](docs/PORTABLE_MEMORY_PROTOCOL_V2.md) |
| The vault format you own | [`docs/LOCAL_VAULT_FORMAT.md`](docs/LOCAL_VAULT_FORMAT.md) |
-| Connecting AI tools (MCP) | [`docs/MCP_INTEGRATIONS.md`](docs/MCP_INTEGRATIONS.md) |
-| Importing your sources | [`docs/SOURCE_IMPORTS.md`](docs/SOURCE_IMPORTS.md) |
+| Connecting AI tools | [`docs/EXTERNAL_INTEGRATIONS.md`](docs/EXTERNAL_INTEGRATIONS.md) · [`docs/MCP_INTEGRATIONS.md`](docs/MCP_INTEGRATIONS.md) |
+| Importing sources and adding connectors | [`docs/SOURCE_IMPORTS.md`](docs/SOURCE_IMPORTS.md) · [`docs/ADDING_A_CONNECTOR.md`](docs/ADDING_A_CONNECTOR.md) |
| Trust & privacy controls | [`docs/TRUST_CONTROLS.md`](docs/TRUST_CONTROLS.md) |
-| Encryption & sync design | [`docs/ACCOUNTS_ENCRYPTION_DESIGN.md`](docs/ACCOUNTS_ENCRYPTION_DESIGN.md) · [`docs/CXE1_WIRE_FORMAT.md`](docs/CXE1_WIRE_FORMAT.md) |
-| Install & auto-updates | [`docs/INSTALLER_AND_UPDATES.md`](docs/INSTALLER_AND_UPDATES.md) |
+| Reproducible benchmark results | [`docs/BENCHMARKS.md`](docs/BENCHMARKS.md) |
+| Open-source readiness review | [`docs/OPEN_SOURCE_READINESS.md`](docs/OPEN_SOURCE_READINESS.md) |
+| Examples | [`examples/README.md`](examples/README.md) |
+| FAQ and troubleshooting | [`docs/FAQ.md`](docs/FAQ.md) · [`docs/TROUBLESHOOTING.md`](docs/TROUBLESHOOTING.md) |
+| Sync & encryption | [`docs/E2EE_SYNC_DESIGN.md`](docs/E2EE_SYNC_DESIGN.md) · [`docs/CXE1_WIRE_FORMAT.md`](docs/CXE1_WIRE_FORMAT.md) |
+| Install & update checks | [`docs/INSTALLER_AND_UPDATES.md`](docs/INSTALLER_AND_UPDATES.md) |
+| Release & distribution | [`docs/DISTRIBUTION.md`](docs/DISTRIBUTION.md) · [`docs/APPLE_RELEASE.md`](docs/APPLE_RELEASE.md) |
+| Experimental pairwise twin evaluation | [Overview](https://github.com/trace-cortex/cortex-app/blob/feat/pairwise-twin-eval/docs/PAIRWISE_TWIN_EVALUATION.md) · [Integration guide](https://github.com/trace-cortex/cortex-app/blob/feat/pairwise-twin-eval/docs/PAIRWISE_TWIN_INTEGRATION_GUIDE.md) |
+
+## Build with us
+
+Cortex is an early open-source beta, and small, evidence-backed contributions
+are welcome. You do not need a personal vault to help: the demo, examples, and
+evaluation fixtures all use synthetic data.
+
+| Bring… | Start here |
+|---|---|
+| A reproducible bug | [Open a bug report](https://github.com/trace-cortex/cortex-app/issues/new?template=bug_report.yml) |
+| Installation friction | [Ask for setup help](https://github.com/trace-cortex/cortex-app/issues/new?template=installation_help.yml) |
+| A focused product idea | [Propose a feature](https://github.com/trace-cortex/cortex-app/issues/new?template=feature_request.yml) |
+| An integration you built | [Share it with the community](https://github.com/trace-cortex/cortex-app/issues/new?template=showcase.yml) |
+| A code or docs contribution | Read [`CONTRIBUTING.md`](CONTRIBUTING.md) and run `make check` |
+
+Please keep real memories, exports, tokens, and vaults out of issues. Report
+vulnerabilities privately through [`SECURITY.md`](SECURITY.md). Project
+direction, support boundaries, and releases live in
+[`ROADMAP.md`](ROADMAP.md), [`SUPPORT.md`](SUPPORT.md), and
+[`CHANGELOG.md`](CHANGELOG.md).
## Privacy
-Cortex is local-first: the default experience needs no account and no cloud. Cortex reads a source only
-after you connect it, records nothing ambient (no screen, no microphone), and shares context with an AI
-tool only within the scoped permission you grant. The optional Cortex Cloud tier (for multi-device sync)
-is described honestly in the app and on the site. Questions: **sdoven@uwaterloo.ca** or **vamika_singhal@berkeley.edu**.
+Your memory is stored as plain files on your Mac at
+`~/Library/Application Support/Cortex/Cortex.vault/`, and ingestion, retrieval, Ask, and the profile all
+run locally on the bundled engine — embeddings included. Cortex reads a source only after you connect
+it and records nothing ambient: there is no microphone access, and screen capture happens only when you
+trigger it. Context goes to an AI tool only within the scoped permission you grant, with redaction on by
+default.
+
+Two things worth being precise about. The current beta build requires a one-time account sign-in for
+identity and sync, so it is not account-free — though your memory stays local either way. And synced
+content is readable server-side unless you turn on client-side encryption, which is off by default.
+Questions: **sdoven@uwaterloo.ca** or **vamika_singhal@berkeley.edu**.
## Affiliations & sponsor
@@ -182,8 +475,11 @@ Cortex is released under the **[MIT License](LICENSE)**, free to use, modify, an
-**Cortex is your personal operating model for AI:** the cited, local, portable model of how you work,
-that dreams while you rest and calibrates every tool you use.
+**One memory you can inspect. Cited context your tools can use.**
+
+
-The more you bring in, the more your tools act the way you would.
+[⭐ Star Cortex](https://github.com/trace-cortex/cortex-app)
+· [Download the beta](https://github.com/doppl-tech/releases/releases/latest)
+· [Build an integration](examples/README.md)
diff --git a/ROADMAP.md b/ROADMAP.md
new file mode 100644
index 00000000..091c08b1
--- /dev/null
+++ b/ROADMAP.md
@@ -0,0 +1,47 @@
+# Cortex Roadmap
+
+This roadmap communicates direction, not guaranteed dates. Current behavior is
+defined by executable tests, the application code, and the current-product
+documents indexed in [docs/README.md](docs/README.md).
+
+## Now — Make the Beta Trustworthy
+
+- Improve installation success and clean-profile first run.
+- Keep Review understandable under large, noisy imports.
+- Increase cited-answer quality and honest abstention.
+- Validate the thirteen read-only connector contracts against real accounts.
+- Harden backup, restore, deletion, support bundles, and update handoff.
+- Keep local and hosted API behavior aligned.
+- Maintain versioned, reproducible benchmark reports and runnable integration
+ examples as the implementation evolves.
+
+## Next — Prove the Operating Model
+
+- Run the pairwise digital-twin owner study and satisfy its admission gates.
+- Improve whole-person adaptation without weakening source provenance.
+- Configure and validate managed OAuth providers release by release.
+- Validate retrieval, context packing, latency, and answerability against
+ consented real-user tasks in addition to deterministic synthetic fixtures.
+
+Pairwise evaluation remains experimental on `feat/pairwise-twin-eval`; see its
+[overview](https://github.com/trace-cortex/cortex-app/blob/feat/pairwise-twin-eval/docs/PAIRWISE_TWIN_EVALUATION.md).
+
+## Later — Scale Without Losing Ownership
+
+- Production hardening for hosted auth, storage, monitoring, and incident
+ response.
+- Multi-device encrypted sync with a recovery experience users can understand.
+- A stable extension/plugin ecosystem around the portable-memory protocol.
+- Published, versioned Python and TypeScript SDKs.
+- Team and enterprise features only after the personal product is reliably
+ useful.
+
+## Non-Goals
+
+- Ambient surveillance or background capture without explicit consent.
+- Uncited answers presented as personal memory.
+- Moving the authoritative personal vault into a proprietary format.
+- Making every experimental feature part of the default product.
+
+Propose roadmap changes through the feature-request template with user evidence,
+scope, alternatives, and success criteria.
diff --git a/SECURITY.md b/SECURITY.md
new file mode 100644
index 00000000..1775f609
--- /dev/null
+++ b/SECURITY.md
@@ -0,0 +1,58 @@
+# Security Policy
+
+Cortex stores highly personal data and exposes it to explicitly authorized
+tools. Security and privacy regressions are treated as product failures.
+
+## Reporting a Vulnerability
+
+Please do **not** open a public issue for a suspected vulnerability.
+
+Email **support@trydoppl.com** with the subject `Cortex security report` and
+include:
+
+- the affected commit, release, endpoint, or component;
+- reproduction steps or a minimal proof of concept;
+- expected and observed behavior;
+- realistic impact and any prerequisites;
+- whether you believe active exploitation is occurring.
+
+Do not send real tokens, private vaults, raw memory, or another person's data.
+Use synthetic fixtures and redact identifiers. We aim to acknowledge reports
+within five business days, validate severity, and coordinate a fix and
+disclosure timeline with the reporter.
+
+## Supported Versions
+
+Security fixes target:
+
+- the current `main` branch;
+- the latest direct-download beta release;
+- the current published SDK/plugin versions, when any are available.
+
+Older beta artifacts may be asked to update before a report can be reproduced.
+
+## High-Sensitivity Areas
+
+Especially valuable reports include:
+
+- authentication or scope bypasses;
+- cross-user or cross-vault access;
+- unsafe MCP tool authorization;
+- connector-token or Keychain leakage;
+- path traversal or unsafe archive extraction;
+- support-bundle or log disclosure;
+- update/release integrity failures;
+- encryption, deletion, backup, or restore failures;
+- remote requests that can reach unintended hosts or local resources.
+
+The current threat model and controls are described in
+[docs/SECURITY_REVIEW.md](docs/SECURITY_REVIEW.md) and
+[docs/TRUST_CONTROLS.md](docs/TRUST_CONTROLS.md).
+
+## Safe Research
+
+Use accounts and data you control, avoid service disruption and automated
+high-volume scanning, stop if you encounter another user's information, and
+give maintainers reasonable time to remediate before public disclosure.
+
+There is currently no paid bug-bounty program.
diff --git a/SETUP.md b/SETUP.md
index 751cc8ca..d5d58436 100644
--- a/SETUP.md
+++ b/SETUP.md
@@ -1,15 +1,56 @@
# Cortex Setup Guide
-Cortex is a local-first macOS beta. You do not need Redis, a GitHub token, Docker, or a hosted account to run the current app.
+Cortex is a local-first macOS beta. You do not need Redis, Docker, or an
+external vector database. The current direct-download build requires account
+sign-in; retrieval and the user-owned vault remain local.
-## Build And Open
+## Choose a setup path
+
+### Evaluate the engine
+
+This is the safest first source run: it uses synthetic data and deletes its
+temporary vault.
+
+```bash
+make doctor
+make demo
+```
+
+### Use the desktop app
+
+Download the current notarized DMG from the
+[release page](https://github.com/doppl-tech/releases/releases/latest). Building
+from source is a contributor workflow, not the shortest installation path.
+
+### Develop the backend or SDKs
+
+```bash
+make setup
+make run
+```
+
+Open `http://127.0.0.1:8766/docs` for the interactive API.
+
+## Requirements
+
+- Backend/demo: Python 3.12 on macOS or Linux
+- TypeScript SDK: Node.js 18+; Obsidian/OpenClaw plugins: Node.js 22
+- Desktop app: Apple Silicon Mac running macOS 13+ and Xcode command line tools
+
+## Build the macOS UI from source
```bash
./macos/build.sh
open macos/build/Cortex.app
```
-The release package starts its bundled local service on `127.0.0.1:8766` and stores user-owned memory files in:
+The default source build is an ad-hoc-signed UI development bundle without the
+release Python runtime or embedding model. It is useful for SwiftUI work but is
+not a full replacement for the downloadable app. See
+[docs/APPLE_RELEASE.md](docs/APPLE_RELEASE.md) for a release-like bundle.
+
+The release package starts its bundled local service on `127.0.0.1:8766` and
+stores user-owned memory files in:
```text
~/Library/Application Support/Cortex/Cortex.vault/
@@ -48,21 +89,27 @@ Open Connections & Privacy to manage:
- source/audit trail
- advanced diagnostics and setup reset
-## Local Backend Development
+`make setup` refuses to continue with the system Python when it is not 3.12,
+creates `.venv`, and installs the exact backend/test versions in
+`requirements-dev.lock`, verifying every downloaded artifact hash. To use an
+explicit interpreter:
+
+```bash
+make setup PYTHON=/opt/homebrew/bin/python3.12
+```
-For backend development without opening the app:
+Run the fast local pre-PR set:
```bash
-./scripts/dev_backend.sh
+make check
```
-Run the main verification set:
+Run the broader verification set:
```bash
-python3 -W error::ResourceWarning -m unittest discover backend/tests
-python3 scripts/retrieval_eval.py
-python3 scripts/adaptation_eval.py
+make test
+make connector-check
./macos/build.sh
codesign --verify --deep --strict --verbose=2 macos/build/Cortex.app
-python3 scripts/ops_readiness_check.py
+.venv/bin/python scripts/ops_readiness_check.py
```
diff --git a/SUPPORT.md b/SUPPORT.md
new file mode 100644
index 00000000..9e61e2b4
--- /dev/null
+++ b/SUPPORT.md
@@ -0,0 +1,48 @@
+# Cortex Support
+
+## Choose the Right Channel
+
+- **Installation problem:** open a GitHub issue using the installation-help
+ template.
+- **Reproducible product bug:** open a GitHub issue using the bug-report
+ template.
+- **Feature or design proposal:** open a feature request after checking
+ [ROADMAP.md](ROADMAP.md).
+- **Security or private-data concern:** follow [SECURITY.md](SECURITY.md); do
+ not post it publicly.
+- **Conduct concern:** follow [CODE_OF_CONDUCT.md](CODE_OF_CONDUCT.md).
+
+## Before Filing a Bug
+
+1. Confirm you are testing the latest release or current `main`.
+2. Check [docs/TROUBLESHOOTING.md](docs/TROUBLESHOOTING.md).
+3. For source/setup problems, run the read-only prerequisite check and isolated
+ demo:
+
+ ```bash
+ make doctor
+ make demo
+ ```
+
+ `make doctor` changes nothing. `make demo` uses only disposable synthetic
+ data and removes its temporary vault.
+
+4. For a running app, create a sanitized support bundle from Connections &
+ Privacy or:
+
+ ```bash
+ .venv/bin/python scripts/export_support_bundle.py --mode live
+ ```
+
+Support bundles are designed to omit raw memory, but review the generated file
+before sharing it. Never attach a vault, connector credential, API token, or
+private export to a public issue.
+
+## Useful Bug Details
+
+Include the Cortex version/build, macOS version and architecture, installation
+method, exact steps, expected behavior, actual behavior, and the smallest
+sanitized log or diagnostic excerpt that demonstrates the problem.
+
+Cortex is currently a beta. Community support is best-effort and has no
+guaranteed response time.
diff --git a/backend/README.md b/backend/README.md
index 91e2617f..b77c8449 100644
--- a/backend/README.md
+++ b/backend/README.md
@@ -1,6 +1,25 @@
# Cortex Backend
-FastAPI backend for the Cortex MVP.
+The maintained Python core and FastAPI development/hosted runtime. New
+contributors should use the repository-root commands; they pin Python and
+dependencies consistently with CI.
+
+## Start here
+
+From the repository root:
+
+```bash
+make demo # isolated synthetic data; starts and stops automatically
+make run # persistent FastAPI server at http://127.0.0.1:8766
+make test # complete backend suite (roughly ten minutes on a laptop)
+```
+
+Interactive API documentation is available at
+`http://127.0.0.1:8766/docs` while `make run` is active. The stdlib server
+shipped inside the macOS app can be exercised with `make run-standalone`.
+
+Do not create a second virtual environment under `backend/`; `make setup`
+creates the canonical root `.venv`.
It provides:
@@ -14,7 +33,8 @@ It provides:
- user-owned local vault persistence
- SQLite/FTS rebuildable local index
- full-text search
-- optional `sqlite-vec` vector search with offline hash embeddings by default and opt-in OpenAI embeddings
+- optional `sqlite-vec` vector search with bundled on-device Model2Vec
+ embeddings, a deterministic hash fallback, and opt-in OpenAI embeddings
- review inbox
- capture approve/archive/delete lifecycle
- daily review with recommended actions
@@ -27,18 +47,6 @@ It provides:
- diagnostics, reliability reports, support bundles, backups, repair, and search maintenance
- MCP-style JSON-RPC tools
-## Local Run
-
-```bash
-cd backend
-python3 -m venv .venv
-source .venv/bin/activate
-pip install -r requirements.txt
-uvicorn app.main:app --reload --host 127.0.0.1 --port 8766
-```
-
-The macOS app defaults to `http://127.0.0.1:8766`.
-
## Environment
```bash
@@ -57,7 +65,15 @@ OPENAI_API_KEY=optional
If `ANTHROPIC_API_KEY` is missing, the backend uses a deterministic local extractor so capture/search still work.
-If `CORTEX_EMBEDDING_PROVIDER=openai`, Cortex calls OpenAI's embeddings endpoint with the configured model and stores the resulting vectors in the rebuildable SQLite index. Leave the provider as `hash` for fully offline local search. Non-strict OpenAI mode falls back to hash embeddings when the provider is unavailable; set `CORTEX_EMBEDDING_STRICT=1` when indexing should fail instead. Keep `CORTEX_EMBEDDING_DIMENSIONS=384` for the current local sqlite-vec schema.
+With no explicit provider override, Cortex uses the bundled on-device Model2Vec
+model when it is present and falls back to `cortex-hash-v1` when it is not.
+`minishlab/potion-base-8M` produces native 256-dimensional vectors; the
+rebuildable sqlite-vec index reconciles its dimensions to the active provider.
+Set `CORTEX_EMBEDDING_PROVIDER=hash` for the deterministic development/CI
+fallback. If `CORTEX_EMBEDDING_PROVIDER=openai`, Cortex calls OpenAI's embeddings
+endpoint with the configured model. Non-strict provider failures fall back to
+hash retrieval; set `CORTEX_EMBEDDING_STRICT=1` when failures should stop
+indexing instead.
## Core Endpoints
@@ -147,7 +163,7 @@ See `docs/LOCAL_VAULT_FORMAT.md` for the full disk layout and recovery contract.
## Battle Tests
```bash
-python3 -m unittest discover backend/tests
+python3 -m pytest backend/tests -q
python3 scripts/backend_beta_smoke.py
```
@@ -162,7 +178,10 @@ python3 scripts/ops_readiness_check.py --require-live
## Hosted Development Notes
-These notes are experimental. The first-100 beta does not include hosted accounts, cloud sync, remote recovery, or a 10k-user hosted platform.
+These notes cover the hosted plane as well as local development. The current direct build requires
+account sign-in and includes optional capture sync; zero-access client encryption is opt-in. That does
+not make the service a production-ready 10k-user platform: remote recovery, managed multi-tenant
+operations, formal support, and broad observability remain incomplete.
The same FastAPI service can be deployed to Render, Fly.io, Railway, or a small VPS for development. For hosted experiments, keep SQLite WAL plus `sqlite-vec` as the primary memory store, add login/API-token auth, encrypted backups, and eventually per-user or per-shard database files.
diff --git a/backend/app/authn.py b/backend/app/authn.py
index 2dadd61a..7f700e65 100644
--- a/backend/app/authn.py
+++ b/backend/app/authn.py
@@ -32,7 +32,10 @@
import hashlib
import hmac
import json
+import os
import secrets
+import threading
+from contextlib import contextmanager
from datetime import datetime, timedelta, timezone
from typing import Any, Callable, Optional
@@ -79,6 +82,31 @@ def __init__(self) -> None:
super().__init__("rate limited")
+# Argon2id uses 64 MiB per operation. Hosted deployments accept concurrent auth
+# requests, so an unbounded thread-pool burst can exhaust the service memory limit
+# before per-identifier rate limits help. Bound all password work in this process;
+# multi-worker deployments multiply this small cap rather than the request burst.
+def _password_work_limit() -> int:
+ try:
+ configured = int(os.environ.get("CORTEX_PASSWORD_HASH_CONCURRENCY", "2") or "2")
+ except ValueError:
+ configured = 2
+ return max(1, min(configured, 8))
+
+
+_PASSWORD_WORK_SLOTS = threading.BoundedSemaphore(_password_work_limit())
+
+
+@contextmanager
+def _password_work_slot():
+ if not _PASSWORD_WORK_SLOTS.acquire(timeout=5):
+ raise RateLimited()
+ try:
+ yield
+ finally:
+ _PASSWORD_WORK_SLOTS.release()
+
+
# --------------------------------------------------------------------------
# Token discipline (mirrors sharding.TokenControlIndex exactly)
# --------------------------------------------------------------------------
@@ -168,9 +196,10 @@ def __init__(
# -- minting -----------------------------------------------------------
def hash(self, password: str) -> str:
- if self._hasher is not None:
- return self._hasher.hash(password)
- return self._scrypt_hash(password)
+ with _password_work_slot():
+ if self._hasher is not None:
+ return self._hasher.hash(password)
+ return self._scrypt_hash(password)
def _scrypt_hash(self, password: str) -> str:
salt = secrets.token_bytes(16)
@@ -194,28 +223,29 @@ def _scrypt_derive(password: str, salt: bytes, ln: int, r: int, p: int) -> bytes
def verify(self, phc: str, password: str) -> bool:
if not phc or not isinstance(phc, str):
return False
- if phc.startswith("$argon2"):
- if self._argon2_verifier is None:
- return False
- try:
- return bool(self._argon2_verifier.verify(phc, password))
- except (
- _argon2_exceptions.VerificationError,
- _argon2_exceptions.InvalidHashError,
- ValueError,
- ):
- return False
- if phc.startswith("$scrypt$"):
- parsed = self._parse_scrypt(phc)
- if parsed is None:
- return False
- ln, r, p, salt, expected = parsed
- try:
- derived = self._scrypt_derive(password, salt, ln, r, p)
- except (ValueError, MemoryError):
- return False
- return hmac.compare_digest(derived, expected)
- return False
+ with _password_work_slot():
+ if phc.startswith("$argon2"):
+ if self._argon2_verifier is None:
+ return False
+ try:
+ return bool(self._argon2_verifier.verify(phc, password))
+ except (
+ _argon2_exceptions.VerificationError,
+ _argon2_exceptions.InvalidHashError,
+ ValueError,
+ ):
+ return False
+ if phc.startswith("$scrypt$"):
+ parsed = self._parse_scrypt(phc)
+ if parsed is None:
+ return False
+ ln, r, p, salt, expected = parsed
+ try:
+ derived = self._scrypt_derive(password, salt, ln, r, p)
+ except (ValueError, MemoryError):
+ return False
+ return hmac.compare_digest(derived, expected)
+ return False
@staticmethod
def _parse_scrypt(phc: str) -> tuple[int, int, int, bytes, bytes] | None:
diff --git a/backend/app/config.py b/backend/app/config.py
index c05c46b4..bdac63b1 100644
--- a/backend/app/config.py
+++ b/backend/app/config.py
@@ -80,6 +80,8 @@ class Settings:
auth_access_ttl_seconds: int = 0 # 0 = authn.py default (1h)
auth_refresh_idle_ttl_seconds: int = 0 # 0 = authn.py default (30d sliding)
auth_refresh_absolute_ttl_seconds: int = 0 # 0 = authn.py default (90d absolute)
+ # Fail closed for hosted account creation until approved legal text is live.
+ legal_terms_approved: bool = False
auth_email_mode: str = "log" # "log" (console sink) | "smtp"
# SMTP delivery (used only when auth_email_mode == "smtp"). Read from
# CORTEX_SMTP_*. If mode is "smtp" but no host is configured, AuthRuntime
@@ -237,7 +239,16 @@ def load_settings() -> Settings:
or bool(oidc_apple_client_id)
or bool(accounts_db_env)
)
- public_base_url = os.environ.get("CORTEX_PUBLIC_BASE_URL", "http://127.0.0.1:8766")
+ configured_public_base = os.environ.get("CORTEX_PUBLIC_BASE_URL", "").strip()
+ if configured_public_base:
+ public_base_url = configured_public_base
+ else:
+ # Development servers can move off 8766 when the desktop app already owns
+ # that port. Keep discovery and generated callback URLs on the same origin.
+ configured_port = int(os.environ.get("CORTEX_PORT", "8766") or "8766")
+ if configured_port < 1 or configured_port > 65535:
+ raise ValueError("CORTEX_PORT must be between 1 and 65535")
+ public_base_url = f"http://127.0.0.1:{configured_port}"
api_key = os.environ.get("CORTEX_API_KEY", "").strip()
if api_key == INSECURE_DEV_API_KEY and not _truthy_env("CORTEX_ALLOW_INSECURE_DEV_TOKEN"):
raise RuntimeError(
@@ -277,6 +288,7 @@ def load_settings() -> Settings:
auth_access_ttl_seconds=max(0, int(os.environ.get("CORTEX_AUTH_ACCESS_TTL_SECONDS", "0") or "0")),
auth_refresh_idle_ttl_seconds=max(0, int(os.environ.get("CORTEX_AUTH_REFRESH_IDLE_TTL_SECONDS", "0") or "0")),
auth_refresh_absolute_ttl_seconds=max(0, int(os.environ.get("CORTEX_AUTH_REFRESH_ABSOLUTE_TTL_SECONDS", "0") or "0")),
+ legal_terms_approved=_truthy_env("CORTEX_LEGAL_TERMS_APPROVED"),
auth_email_mode=(os.environ.get("CORTEX_AUTH_EMAIL_MODE", "log").strip().lower() or "log"),
smtp_host=os.environ.get("CORTEX_SMTP_HOST", "").strip(),
smtp_port=max(1, int(os.environ.get("CORTEX_SMTP_PORT", "587") or "587")),
diff --git a/backend/app/connector_policy.py b/backend/app/connector_policy.py
new file mode 100644
index 00000000..93bb56d0
--- /dev/null
+++ b/backend/app/connector_policy.py
@@ -0,0 +1,52 @@
+"""Shared outbound-origin policy for credential-bearing hosted connectors."""
+
+from __future__ import annotations
+
+from urllib.parse import urlsplit
+
+
+HOSTED_CONNECTOR_ORIGINS: dict[str, tuple[str, ...]] = {
+ "github": ("https://api.github.com",),
+ "gmail": ("https://gmail.googleapis.com/gmail/v1",),
+ "google-drive": ("https://www.googleapis.com/drive/v3",),
+ "outlook": ("https://graph.microsoft.com/v1.0",),
+ "slack": ("https://slack.com/api",),
+ "readwise": ("https://readwise.io/api/v2",),
+ "raindrop": ("https://api.raindrop.io/rest/v1",),
+ "linear": ("https://api.linear.app/graphql",),
+ "notion": ("https://api.notion.com/v1",),
+}
+
+HOSTED_OAUTH_TOKEN_ENDPOINTS: dict[str, tuple[str, ...]] = {
+ "gmail": ("https://oauth2.googleapis.com/token",),
+ "google-drive": ("https://oauth2.googleapis.com/token",),
+ "notion": ("https://api.notion.com/v1/oauth/token",),
+ "outlook": ("https://login.microsoftonline.com/common/oauth2/v2.0/token",),
+}
+
+
+def is_official_connector_origin(source: str, value: str) -> bool:
+ candidate = str(value or "").strip().rstrip("/")
+ return candidate in HOSTED_CONNECTOR_ORIGINS.get(source, ())
+
+
+def is_official_oauth_token_endpoint(source: str, value: str) -> bool:
+ candidate = str(value or "").strip().rstrip("/")
+ return candidate in HOSTED_OAUTH_TOKEN_ENDPOINTS.get(source, ())
+
+
+def is_atlassian_cloud_origin(value: str) -> bool:
+ parsed = urlsplit(str(value or "").strip())
+ hostname = (parsed.hostname or "").lower()
+ try:
+ port = parsed.port
+ except ValueError:
+ return False
+ return (
+ parsed.scheme == "https"
+ and parsed.username is None
+ and parsed.password is None
+ and port in (None, 443)
+ and hostname.endswith(".atlassian.net")
+ and hostname != ".atlassian.net"
+ )
diff --git a/backend/app/connectors/agent_sessions.py b/backend/app/connectors/agent_sessions.py
index 09d9fb11..8e2ca4aa 100644
--- a/backend/app/connectors/agent_sessions.py
+++ b/backend/app/connectors/agent_sessions.py
@@ -254,7 +254,7 @@ def _claude_records(root: Path, path: Path, *, session_cap: int) -> list[AgentSe
cleaned = _clean_user_text(text)
if not cleaned:
continue
- fingerprint = sha1(cleaned.encode("utf-8")).hexdigest()
+ fingerprint = sha1(cleaned.encode("utf-8"), usedforsecurity=False).hexdigest()
if fingerprint in seen_texts:
continue
seen_texts.add(fingerprint)
@@ -316,7 +316,7 @@ def _codex_records(root: Path, path: Path, *, session_cap: int) -> list[AgentSes
cleaned = _clean_user_text(str(payload.get("message") or ""))
if not cleaned:
continue
- fingerprint = sha1(cleaned.encode("utf-8")).hexdigest()
+ fingerprint = sha1(cleaned.encode("utf-8"), usedforsecurity=False).hexdigest()
if fingerprint in seen_texts:
continue
seen_texts.add(fingerprint)
@@ -370,7 +370,7 @@ def _cursor_records(root: Path, path: Path, *, mtime: float, session_cap: int) -
cleaned = _clean_user_text(str(prompt.get("text") or ""))
if not cleaned:
continue
- fingerprint = sha1(cleaned.encode("utf-8")).hexdigest()
+ fingerprint = sha1(cleaned.encode("utf-8"), usedforsecurity=False).hexdigest()
if fingerprint in seen_texts:
continue
seen_texts.add(fingerprint)
@@ -465,7 +465,7 @@ def _stable_external_id(agent: str, session_id: str, anchor: str) -> str:
raw = f"{agent}:{session_id}:{anchor}"
if len(raw) <= 240:
return raw
- digest = sha1(raw.encode("utf-8")).hexdigest()[:16]
+ digest = sha1(raw.encode("utf-8"), usedforsecurity=False).hexdigest()[:16]
return f"{raw[:220]}#{digest}"
diff --git a/backend/app/connectors/github.py b/backend/app/connectors/github.py
index 39cb0ce5..9802b6e4 100644
--- a/backend/app/connectors/github.py
+++ b/backend/app/connectors/github.py
@@ -5,8 +5,9 @@
import re
from typing import Any, Callable
from urllib.parse import urlencode, urlparse
-from urllib.request import Request, urlopen
+from urllib.request import Request
+from ..http_security import open_same_origin
from ._redaction import connector_error_payload
@@ -45,7 +46,7 @@ def _github_form_post(url: str, form: dict[str, str]) -> dict[str, Any]:
headers={"Content-Type": "application/x-www-form-urlencoded", "Accept": "application/json"},
method="POST",
)
- with urlopen(request, timeout=30) as response: # noqa: S310 — fixed GitHub OAuth endpoints only
+ with open_same_origin(request, timeout=30) as response:
return json.loads(response.read().decode("utf-8"))
@@ -418,7 +419,7 @@ def fetch_github_records(
def _request_json(url: str, headers: dict[str, str]) -> Any:
request = Request(url, headers=headers, method="GET")
- with urlopen(request, timeout=30) as response: # noqa: S310 - user-provided token, trusted GitHub API URL by default.
+ with open_same_origin(request, timeout=30) as response:
return json.loads(response.read().decode("utf-8"))
diff --git a/backend/app/connectors/gmail.py b/backend/app/connectors/gmail.py
index 66355477..da574520 100644
--- a/backend/app/connectors/gmail.py
+++ b/backend/app/connectors/gmail.py
@@ -9,9 +9,10 @@
import re
from typing import Any, Callable
from urllib.parse import quote, urlencode
-from urllib.request import Request, urlopen
+from urllib.request import Request
from ..config import APP_BRAND
+from ..http_security import open_same_origin
from ._redaction import connector_error_payload
@@ -170,7 +171,7 @@ def fetch_gmail_records(
def _request_json(url: str, headers: dict[str, str]) -> Any:
request = Request(url, headers=headers, method="GET")
- with urlopen(request, timeout=30) as response: # noqa: S310 - trusted Gmail API URL by default.
+ with open_same_origin(request, timeout=30) as response:
return json.loads(response.read().decode("utf-8"))
diff --git a/backend/app/connectors/google_drive.py b/backend/app/connectors/google_drive.py
index 524c4e49..21de43d7 100644
--- a/backend/app/connectors/google_drive.py
+++ b/backend/app/connectors/google_drive.py
@@ -7,9 +7,10 @@
import re
from typing import Any, Callable
from urllib.parse import quote, urlencode
-from urllib.request import Request, urlopen
+from urllib.request import Request
from ..config import APP_BRAND
+from ..http_security import open_same_origin
from ._redaction import connector_error_payload
@@ -185,7 +186,7 @@ def fetch_google_drive_records(
def _request_value(url: str, headers: dict[str, str]) -> Any:
request = Request(url, headers=headers, method="GET")
- with urlopen(request, timeout=30) as response: # noqa: S310 - trusted Google API URL by default.
+ with open_same_origin(request, timeout=30) as response:
body = response.read().decode("utf-8", errors="replace")
content_type = response.headers.get("Content-Type", "")
if "application/json" in content_type:
diff --git a/backend/app/connectors/jira.py b/backend/app/connectors/jira.py
index 9f735f26..293f663a 100644
--- a/backend/app/connectors/jira.py
+++ b/backend/app/connectors/jira.py
@@ -7,8 +7,9 @@
import re
from typing import Any, Callable
from urllib.parse import urlsplit, urlunsplit
-from urllib.request import Request, urlopen
+from urllib.request import Request
+from ..http_security import open_same_origin
from ._redaction import classify_error_message, connector_error_payload
@@ -184,7 +185,7 @@ def fetch_jira_records(
def _request_json(url: str, headers: dict[str, str], body: dict[str, Any]) -> Any:
request = Request(url, data=json.dumps(body).encode("utf-8"), headers=headers, method="POST")
- with urlopen(request, timeout=30) as response: # noqa: S310 - trusted Jira site URL provided by the user.
+ with open_same_origin(request, timeout=30) as response:
return json.loads(response.read().decode("utf-8"))
diff --git a/backend/app/connectors/linear.py b/backend/app/connectors/linear.py
index 14301206..3a84c65e 100644
--- a/backend/app/connectors/linear.py
+++ b/backend/app/connectors/linear.py
@@ -4,8 +4,9 @@
import json
import re
from typing import Any, Callable
-from urllib.request import Request, urlopen
+from urllib.request import Request
+from ..http_security import open_same_origin
from ._redaction import classify_error_message, connector_error_payload, redact_error_message
@@ -170,7 +171,7 @@ def fetch_linear_records(
def _request_json(url: str, headers: dict[str, str], body: dict[str, Any]) -> Any:
request = Request(url, data=json.dumps(body).encode("utf-8"), headers=headers, method="POST")
- with urlopen(request, timeout=30) as response: # noqa: S310 - trusted Linear API URL by default.
+ with open_same_origin(request, timeout=30) as response:
return json.loads(response.read().decode("utf-8"))
diff --git a/backend/app/connectors/notion.py b/backend/app/connectors/notion.py
index 282009dd..39d6f8c8 100644
--- a/backend/app/connectors/notion.py
+++ b/backend/app/connectors/notion.py
@@ -5,8 +5,9 @@
import re
from typing import Any, Callable
from urllib.parse import urlencode
-from urllib.request import Request, urlopen
+from urllib.request import Request
+from ..http_security import open_same_origin
from ._redaction import connector_error_payload
@@ -160,7 +161,7 @@ def fetch_notion_records(
def _request_json(url: str, headers: dict[str, str], body: dict[str, Any] | None, method: str) -> Any:
data = json.dumps(body or {}).encode("utf-8") if body is not None else None
request = Request(url, data=data, headers=headers, method=method)
- with urlopen(request, timeout=30) as response: # noqa: S310 - trusted Notion API URL by default.
+ with open_same_origin(request, timeout=30) as response:
return json.loads(response.read().decode("utf-8"))
diff --git a/backend/app/connectors/outlook.py b/backend/app/connectors/outlook.py
index 8c84863a..20221f78 100644
--- a/backend/app/connectors/outlook.py
+++ b/backend/app/connectors/outlook.py
@@ -7,9 +7,10 @@
import re
from typing import Any, Callable
from urllib.parse import quote, urlencode
-from urllib.request import Request, urlopen
+from urllib.request import Request
from ..config import APP_BRAND
+from ..http_security import open_same_origin
from ._redaction import connector_error_payload
@@ -161,7 +162,7 @@ def fetch_outlook_records(
def _request_json(url: str, headers: dict[str, str]) -> Any:
request = Request(url, headers=headers, method="GET")
- with urlopen(request, timeout=30) as response: # noqa: S310 - trusted Microsoft Graph URL by default.
+ with open_same_origin(request, timeout=30) as response:
return json.loads(response.read().decode("utf-8"))
diff --git a/backend/app/connectors/raindrop.py b/backend/app/connectors/raindrop.py
index 8abb17fb..44b7c7e8 100644
--- a/backend/app/connectors/raindrop.py
+++ b/backend/app/connectors/raindrop.py
@@ -5,8 +5,9 @@
import re
from typing import Any, Callable
from urllib.parse import urlencode
-from urllib.request import Request, urlopen
+from urllib.request import Request
+from ..http_security import open_same_origin
from ._redaction import connector_error_payload
@@ -186,7 +187,7 @@ def fetch_raindrop_records(
def _request_json(url: str, headers: dict[str, str]) -> Any:
request = Request(url, headers=headers, method="GET")
- with urlopen(request, timeout=30) as response: # noqa: S310 - trusted Raindrop API URL by default.
+ with open_same_origin(request, timeout=30) as response:
return json.loads(response.read().decode("utf-8"))
diff --git a/backend/app/connectors/readwise.py b/backend/app/connectors/readwise.py
index 0588164d..359d2a23 100644
--- a/backend/app/connectors/readwise.py
+++ b/backend/app/connectors/readwise.py
@@ -5,8 +5,9 @@
import re
from typing import Any, Callable
from urllib.parse import urlencode
-from urllib.request import Request, urlopen
+from urllib.request import Request
+from ..http_security import open_same_origin
from ._redaction import classify_error_message, connector_error_payload, redact_error_message
@@ -154,7 +155,7 @@ def fetch_readwise_records(
def _request_json(url: str, headers: dict[str, str]) -> Any:
request = Request(url, headers=headers, method="GET")
- with urlopen(request, timeout=30) as response: # noqa: S310 - trusted Readwise API URL by default.
+ with open_same_origin(request, timeout=30) as response:
return json.loads(response.read().decode("utf-8"))
diff --git a/backend/app/connectors/slack.py b/backend/app/connectors/slack.py
index 3ddfc161..c6ec34fd 100644
--- a/backend/app/connectors/slack.py
+++ b/backend/app/connectors/slack.py
@@ -7,8 +7,9 @@
import re
from typing import Any, Callable
from urllib.parse import urlencode
-from urllib.request import Request, urlopen
+from urllib.request import Request
+from ..http_security import open_same_origin
from ._redaction import connector_error_payload
@@ -409,7 +410,7 @@ def _fetch_thread_reply_records(
def _request_json(url: str, headers: dict[str, str]) -> Any:
request = Request(url, headers=headers, method="GET")
- with urlopen(request, timeout=30) as response: # noqa: S310 - user-provided token, trusted Slack API URL by default.
+ with open_same_origin(request, timeout=30) as response:
return json.loads(response.read().decode("utf-8"))
diff --git a/backend/app/connectors/zotero.py b/backend/app/connectors/zotero.py
index 833924f9..27306042 100644
--- a/backend/app/connectors/zotero.py
+++ b/backend/app/connectors/zotero.py
@@ -6,8 +6,9 @@
import re
from typing import Any, Callable
from urllib.parse import urlencode
-from urllib.request import Request, urlopen
+from urllib.request import Request
+from ..http_security import open_same_origin
from ._redaction import connector_error_payload
@@ -180,7 +181,7 @@ def fetch_zotero_records(
def _request_json(url: str, headers: dict[str, str]) -> Any:
request = Request(url, headers=headers, method="GET")
- with urlopen(request, timeout=30) as response: # noqa: S310 - trusted Zotero local/Web API URL by default.
+ with open_same_origin(request, timeout=30) as response:
return json.loads(response.read().decode("utf-8"))
diff --git a/backend/app/delivery.py b/backend/app/delivery.py
index 8949097b..8189cbe5 100644
--- a/backend/app/delivery.py
+++ b/backend/app/delivery.py
@@ -11,18 +11,18 @@
- is gated at the endpoint by the export scope + the allow_agent_exports trust toggle (default
off) and audited.
-Stdlib-only (urllib + socket + ipaddress). The HTTP sender is injectable for deterministic tests.
+Stdlib-only (http.client + socket + ipaddress). The HTTP sender is injectable for deterministic tests.
"""
from __future__ import annotations
import ipaddress
+import http.client
import json
import os
import socket
+import ssl
from typing import Any, Callable
-from urllib.parse import urlparse
-from urllib.request import Request, urlopen
-
+from urllib.parse import SplitResult, urlsplit
DELIVERY_KINDS = ("brief", "digest", "context")
_DEFAULT_TIMEOUT = 15
@@ -32,35 +32,107 @@ def _allow_internal_targets() -> bool:
return os.environ.get("CORTEX_DELIVERY_ALLOW_INTERNAL", "").strip().lower() in {"1", "true", "on", "yes"}
-def is_safe_webhook_url(url: str) -> tuple[bool, str]:
- """SSRF guard. Returns (ok, reason). Refuses non-http(s), credential-bearing, and — unless
- CORTEX_DELIVERY_ALLOW_INTERNAL is set (local dev) — any target that resolves to a loopback,
- private, link-local, reserved, or multicast address. Public targets must be https."""
+def _validated_webhook_target(url: str) -> tuple[SplitResult | None, str | None, str]:
+ """Validate and resolve once, returning the exact address to connect to."""
raw = str(url or "").strip()
- parsed = urlparse(raw)
+ parsed = urlsplit(raw)
if parsed.scheme not in {"http", "https"}:
- return False, "URL must be http(s)"
- if "@" in (parsed.netloc or ""):
- return False, "credentials in URL are not allowed"
+ return None, None, "URL must be http(s)"
+ if parsed.username is not None or parsed.password is not None:
+ return None, None, "credentials in URL are not allowed"
host = parsed.hostname
if not host:
- return False, "URL has no host"
- if _allow_internal_targets():
- return True, ""
+ return None, None, "URL has no host"
try:
- infos = socket.getaddrinfo(host, parsed.port or (443 if parsed.scheme == "https" else 80), proto=socket.IPPROTO_TCP)
- except OSError:
- return False, "host does not resolve"
+ port = parsed.port or (443 if parsed.scheme == "https" else 80)
+ except ValueError:
+ return None, None, "URL has an invalid port"
+ try:
+ infos = socket.getaddrinfo(host, port, type=socket.SOCK_STREAM, proto=socket.IPPROTO_TCP)
+ except (OSError, ValueError):
+ return None, None, "host does not resolve"
+ addresses: list[str] = []
for info in infos:
try:
ip = ipaddress.ip_address(info[4][0])
except ValueError:
- return False, "unresolvable address"
- if ip.is_loopback or ip.is_private or ip.is_link_local or ip.is_reserved or ip.is_multicast or ip.is_unspecified:
- return False, "target resolves to an internal address"
- if parsed.scheme != "https":
- return False, "public targets must use https"
- return True, ""
+ return None, None, "unresolvable address"
+ # ``is_private`` does not cover every non-public range (notably
+ # 100.64.0.0/10 shared address space). Public delivery accepts only
+ # globally routable addresses.
+ if not _allow_internal_targets() and not ip.is_global:
+ return None, None, "target resolves to an internal address"
+ addresses.append(str(ip))
+ if not addresses:
+ return None, None, "host does not resolve"
+ if not _allow_internal_targets() and parsed.scheme != "https":
+ return None, None, "public targets must use https"
+ return parsed, addresses[0], ""
+
+
+def is_safe_webhook_url(url: str) -> tuple[bool, str]:
+ """Refuse unsafe webhook targets and require public HTTPS."""
+ parsed, _address, reason = _validated_webhook_target(url)
+ return parsed is not None, reason
+
+
+class _PinnedHTTPConnection(http.client.HTTPConnection):
+ def __init__(self, host: str, port: int, pinned_address: str, *, timeout: int) -> None:
+ self._pinned_address = pinned_address
+ super().__init__(host, port, timeout=timeout)
+
+ def connect(self) -> None:
+ self.sock = socket.create_connection(
+ (self._pinned_address, self.port),
+ self.timeout,
+ self.source_address,
+ )
+
+
+class _PinnedHTTPSConnection(http.client.HTTPSConnection):
+ def __init__(self, host: str, port: int, pinned_address: str, *, timeout: int) -> None:
+ self._pinned_address = pinned_address
+ super().__init__(
+ host,
+ port,
+ timeout=timeout,
+ context=ssl.create_default_context(),
+ )
+
+ def connect(self) -> None:
+ raw_socket = socket.create_connection(
+ (self._pinned_address, self.port),
+ self.timeout,
+ self.source_address,
+ )
+ # TLS verifies the original hostname even though the TCP connection is
+ # pinned to the already-validated address.
+ self.sock = self._context.wrap_socket(raw_socket, server_hostname=self.host)
+
+
+def _post_to_pinned_target(
+ parsed: SplitResult,
+ address: str,
+ body: bytes,
+ headers: dict[str, str],
+ *,
+ timeout: int,
+) -> int:
+ host = parsed.hostname or ""
+ port = parsed.port or (443 if parsed.scheme == "https" else 80)
+ connection_cls = _PinnedHTTPSConnection if parsed.scheme == "https" else _PinnedHTTPConnection
+ connection = connection_cls(host, port, address, timeout=timeout)
+ target = parsed.path or "/"
+ if parsed.query:
+ target += f"?{parsed.query}"
+ request_headers = {**headers, "Host": parsed.netloc}
+ try:
+ connection.request("POST", target, body=body, headers=request_headers)
+ response = connection.getresponse()
+ response.read(4096)
+ return int(response.status)
+ finally:
+ connection.close()
def build_delivery_payload(
@@ -101,8 +173,8 @@ def deliver_webhook(
) -> dict[str, Any]:
"""Deliver a payload to a webhook (POST JSON). SSRF-guarded. `request_fn(url, body, headers) ->
status_code` is injectable for tests. Returns {ok, status, reason}."""
- safe, reason = is_safe_webhook_url(url)
- if not safe:
+ parsed, pinned_address, reason = _validated_webhook_target(url)
+ if parsed is None or pinned_address is None:
return {"ok": False, "status": 0, "reason": reason}
body = json.dumps(payload, ensure_ascii=False).encode("utf-8")
headers = {"Content-Type": "application/json", "User-Agent": "Cortex-Delivery/1"}
@@ -110,9 +182,13 @@ def deliver_webhook(
if request_fn is not None:
status = int(request_fn(url, body, headers))
else:
- req = Request(url, data=body, headers=headers, method="POST") # noqa: S310 — SSRF-guarded above
- with urlopen(req, timeout=timeout) as response: # noqa: S310
- status = int(getattr(response, "status", 0) or 0)
- except OSError as exc:
+ status = _post_to_pinned_target(
+ parsed,
+ pinned_address,
+ body,
+ headers,
+ timeout=timeout,
+ )
+ except (OSError, http.client.HTTPException, ssl.SSLError) as exc:
return {"ok": False, "status": 0, "reason": f"delivery failed: {exc}"}
return {"ok": 200 <= status < 300, "status": status, "reason": "" if 200 <= status < 300 else f"HTTP {status}"}
diff --git a/backend/app/embeddings.py b/backend/app/embeddings.py
index 5d1cd135..aa3d7ad7 100644
--- a/backend/app/embeddings.py
+++ b/backend/app/embeddings.py
@@ -13,6 +13,8 @@
from pathlib import Path
from typing import Any
+from .http_security import open_same_origin
+
VECTOR_DIMENSIONS = 384
VECTOR_MODEL = "cortex-hash-v1"
@@ -27,9 +29,11 @@
# hash when it does not — so no new asset is bundled and hash-only machines/CI stay byte-identical.
DEFAULT_MODEL2VEC_MODEL = "minishlab/potion-base-8M"
MODEL2VEC_DIMENSIONS = 256
+EMBEDDING_TEXT_RECIPE_VERSION = "source-text-v1"
_MODEL2VEC_MODEL: Any = None
_MODEL2VEC_MODEL_KEY: str | None = None
+_MODEL2VEC_DIMENSIONS_BY_KEY: dict[str, int] = {}
_MODEL2VEC_LOCK = threading.Lock()
# Process-level failure latch: when model2vec is CONFIGURED but a live embed actually
@@ -177,6 +181,9 @@ def configured_embedding_dimensions(default: int = VECTOR_DIMENSIONS) -> int:
# model2vec emits a fixed native dimension (256 for potion-base-8M);
# honour that as the default so status/index_compatible are truthful.
if configured_embedding_provider() == "model2vec":
+ key = resolve_model2vec_path() or configured_embedding_model()
+ if key in _MODEL2VEC_DIMENSIONS_BY_KEY:
+ return _MODEL2VEC_DIMENSIONS_BY_KEY[key]
return MODEL2VEC_DIMENSIONS
return default
try:
@@ -185,6 +192,57 @@ def configured_embedding_dimensions(default: int = VECTOR_DIMENSIONS) -> int:
return default
+def embedding_index_fingerprint(dimensions: int | None = None) -> str:
+ """Identity of vectors that may safely coexist in one sqlite-vec table.
+
+ Equal dimensions do not imply equal vector spaces. Include the effective
+ provider, configured model/revision, local asset identity, native dimension,
+ and text recipe so a model swap triggers a rebuild instead of silently
+ mixing incompatible vectors.
+ """
+ provider = effective_embedding_provider()
+ model = configured_embedding_model() if provider != "hash" else VECTOR_MODEL
+ resolved_dimensions = int(
+ dimensions if dimensions is not None else configured_embedding_dimensions()
+ )
+ payload = {
+ "provider": provider,
+ "model": model,
+ "revision": os.environ.get("CORTEX_EMBEDDING_REVISION", "").strip(),
+ "dimensions": resolved_dimensions,
+ "text_recipe": EMBEDDING_TEXT_RECIPE_VERSION,
+ "local_asset": _local_model_asset_identity() if provider == "model2vec" else "",
+ }
+ canonical = json.dumps(payload, sort_keys=True, separators=(",", ":"))
+ return hashlib.sha256(canonical.encode("utf-8")).hexdigest()
+
+
+def _local_model_asset_identity() -> str:
+ """Cheap replacement detector for a bundled/local Model2Vec directory.
+
+ Hash the small model config and include size/mtime metadata for large model
+ files. Remote/cache users can pin `CORTEX_EMBEDDING_REVISION` explicitly.
+ """
+ raw_path = resolve_model2vec_path()
+ if not raw_path:
+ return ""
+ path = Path(raw_path).expanduser()
+ parts = [str(path.resolve(strict=False))]
+ config = path / "config.json"
+ try:
+ parts.append(hashlib.sha256(config.read_bytes()).hexdigest())
+ except OSError:
+ parts.append("missing-config")
+ for name in ("model.safetensors", "tokenizer.json"):
+ candidate = path / name
+ try:
+ stat = candidate.stat()
+ parts.append(f"{name}:{stat.st_size}:{stat.st_mtime_ns}")
+ except OSError:
+ parts.append(f"{name}:missing")
+ return hashlib.sha256("|".join(parts).encode("utf-8")).hexdigest()
+
+
def embedding_status(schema_dimensions: int | None = None) -> dict[str, Any]:
"""Report the active embedding provider and how it lines up with the vector index.
@@ -298,7 +356,7 @@ def _openai_embedding(text: str, dimensions: int) -> EmbeddingResult:
method="POST",
)
try:
- with urllib.request.urlopen(request, timeout=_openai_timeout_seconds()) as response:
+ with open_same_origin(request, timeout=_openai_timeout_seconds()) as response:
body = json.loads(response.read().decode("utf-8"))
except urllib.error.HTTPError as exc:
detail = exc.read().decode("utf-8", errors="replace")
@@ -390,6 +448,8 @@ def _model2vec_embedding(text: str, dimensions: int) -> EmbeddingResult:
vector = [float(value) for value in (raw.tolist() if hasattr(raw, "tolist") else raw)]
if not vector:
raise ValueError("model2vec produced an empty embedding")
+ key = resolve_model2vec_path() or configured_embedding_model()
+ _MODEL2VEC_DIMENSIONS_BY_KEY[key] = len(vector)
# Report the model's NATIVE dimension truthfully (potion-base-8M = 256), not the requested
# default — the storage layer sizes/rebuilds the vector index from embedding_status().
return EmbeddingResult(
diff --git a/backend/app/hosted_readiness.py b/backend/app/hosted_readiness.py
index ba3d9355..3d289937 100644
--- a/backend/app/hosted_readiness.py
+++ b/backend/app/hosted_readiness.py
@@ -26,6 +26,15 @@ def hosted_readiness_contract(settings: Settings, runtime: dict | None = None) -
checks = [
_scoped_token_check(hosted_mode, requires_scoped_tokens),
+ _credential_encryption_check(
+ hosted_mode,
+ bool(getattr(settings, "require_encrypted_credentials", False)),
+ runtime,
+ ),
+ _legal_terms_check(
+ hosted_mode,
+ bool(getattr(settings, "legal_terms_approved", False)),
+ ),
_public_base_url_check(hosted_mode, settings.public_base_url),
_sync_signing_key_check(hosted_mode, settings.sync_signing_key),
_hosted_database_check(hosted_mode, settings.hosted_database_url, runtime_tier, shard_mode),
@@ -50,6 +59,90 @@ def hosted_readiness_contract(settings: Settings, runtime: dict | None = None) -
}
+def _credential_encryption_check(
+ hosted_mode: bool,
+ enforcement_enabled: bool,
+ runtime: dict | None,
+) -> dict:
+ if not hosted_mode:
+ return {
+ "name": "credential_encryption",
+ "status": "ok",
+ "detail": "Local mode may keep connector credentials in the user-owned local vault.",
+ }
+ evidence = (runtime or {}).get("credential_encryption") if isinstance(runtime, dict) else None
+ keyring_available = bool(
+ isinstance(evidence, dict) and evidence.get("keyring_available")
+ )
+ remaining_plaintext = (
+ evidence.get("remaining_plaintext") if isinstance(evidence, dict) else None
+ )
+ scan_complete = bool(isinstance(evidence, dict) and evidence.get("scan_complete"))
+ unreadable_files = (
+ int(evidence.get("unreadable_files") or 0) if isinstance(evidence, dict) else 0
+ )
+ invalid_records = (
+ int(evidence.get("invalid_records") or 0) if isinstance(evidence, dict) else 0
+ )
+ if (
+ enforcement_enabled
+ and keyring_available
+ and remaining_plaintext == 0
+ and scan_complete
+ and unreadable_files == 0
+ and invalid_records == 0
+ ):
+ return {
+ "name": "credential_encryption",
+ "status": "ok",
+ "detail": (
+ "Hosted credential encryption is enforced, the runtime keyring is "
+ "available, and the complete credential scan found no plaintext."
+ ),
+ }
+ missing = []
+ if not enforcement_enabled:
+ missing.append("CORTEX_REQUIRE_ENCRYPTED_CREDENTIALS=1")
+ if not keyring_available:
+ missing.append("an available KEK-backed runtime keyring")
+ if remaining_plaintext is None:
+ missing.append("a complete plaintext-credential scan")
+ elif remaining_plaintext != 0:
+ missing.append(f"migration of {remaining_plaintext} remaining plaintext credential(s)")
+ if not scan_complete:
+ missing.append("complete (non-truncated) credential scan coverage")
+ if unreadable_files:
+ missing.append(f"repair of {unreadable_files} unreadable credential file(s)")
+ if invalid_records:
+ missing.append(f"repair of {invalid_records} invalid credential record(s)")
+ return {
+ "name": "credential_encryption",
+ "status": "blocked",
+ "detail": "Hosted readiness requires " + " and ".join(missing) + ".",
+ }
+
+
+def _legal_terms_check(hosted_mode: bool, approved: bool) -> dict:
+ if not hosted_mode or approved:
+ return {
+ "name": "legal_terms",
+ "status": "ok",
+ "detail": (
+ "Hosted legal terms are marked approved by the operator."
+ if hosted_mode
+ else "Local mode does not expose hosted public account creation."
+ ),
+ }
+ return {
+ "name": "legal_terms",
+ "status": "blocked",
+ "detail": (
+ "Public signup is disabled until counsel-approved Terms and Privacy "
+ "are deployed and CORTEX_LEGAL_TERMS_APPROVED=1 is set."
+ ),
+ }
+
+
def _scoped_token_check(hosted_mode: bool, requires_scoped_tokens: bool) -> dict:
if hosted_mode and not requires_scoped_tokens:
return {
diff --git a/backend/app/http_security.py b/backend/app/http_security.py
new file mode 100644
index 00000000..9b3195d4
--- /dev/null
+++ b/backend/app/http_security.py
@@ -0,0 +1,65 @@
+"""Safe HTTP primitives for requests that carry credentials.
+
+``urllib`` forwards request headers across redirects by default, including
+``Authorization``. Connector and OAuth requests must therefore reject any
+redirect that changes origin; otherwise an API response could exfiltrate a
+user's bearer token or client secret to another host.
+"""
+
+from __future__ import annotations
+
+from typing import Any
+from urllib.error import HTTPError
+from urllib.parse import urljoin, urlsplit
+from urllib.request import HTTPRedirectHandler, Request, build_opener
+
+
+def _origin(url: str) -> tuple[str, str, int | None] | None:
+ parsed = urlsplit(str(url or "").strip())
+ if (
+ parsed.scheme not in {"http", "https"}
+ or not parsed.hostname
+ or parsed.username is not None
+ or parsed.password is not None
+ ):
+ return None
+ try:
+ explicit_port = parsed.port
+ except ValueError:
+ return None
+ port = explicit_port
+ if port is None:
+ port = 443 if parsed.scheme == "https" else 80
+ return parsed.scheme, parsed.hostname.lower(), port
+
+
+class SameOriginRedirectHandler(HTTPRedirectHandler):
+ """Allow redirects only when scheme, hostname, and effective port match."""
+
+ def redirect_request( # type: ignore[override]
+ self,
+ req: Request,
+ fp: Any,
+ code: int,
+ msg: str,
+ headers: Any,
+ newurl: str,
+ ) -> Request | None:
+ resolved = urljoin(req.full_url, str(newurl or ""))
+ if _origin(req.full_url) != _origin(resolved):
+ raise HTTPError(
+ req.full_url,
+ code,
+ "cross-origin redirect blocked for credential-bearing request",
+ headers,
+ fp,
+ )
+ return super().redirect_request(req, fp, code, msg, headers, resolved)
+
+
+def open_same_origin(request: Request, *, timeout: float = 30) -> Any:
+ """Open a request while preventing credentials from crossing origins."""
+
+ if _origin(request.full_url) is None:
+ raise ValueError("credential-bearing request URL must be absolute HTTP(S) without user info")
+ return build_opener(SameOriginRedirectHandler()).open(request, timeout=timeout)
diff --git a/backend/app/main.py b/backend/app/main.py
index b6211a6c..f60e0edd 100644
--- a/backend/app/main.py
+++ b/backend/app/main.py
@@ -10,7 +10,9 @@
import json
import logging
import os
+from pathlib import Path
import secrets
+import threading
import time
from typing import TYPE_CHECKING, Any
from urllib.parse import parse_qs, urlencode
@@ -19,6 +21,8 @@
from fastapi.encoders import jsonable_encoder
from fastapi.responses import HTMLResponse, JSONResponse, PlainTextResponse, RedirectResponse, StreamingResponse
from fastapi.middleware.cors import CORSMiddleware
+from fastapi.routing import APIRoute
+from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from .accounts import iso_utc, utc_now
from .authn import (
@@ -30,8 +34,14 @@
token_verify_hash,
)
from .config import APP_BRAND, load_settings
+from .connector_policy import (
+ is_atlassian_cloud_origin,
+ is_official_connector_origin,
+ is_official_oauth_token_endpoint,
+)
from .extractor import extract_context
from .hosted_readiness import hosted_readiness_contract
+from .http_security import open_same_origin
from .mcp_tools import (
CORE_TOOL_NAMES,
MCP_TOOL_SURFACES,
@@ -50,6 +60,7 @@
from .observability import metrics, route_label
from .models import AgentSessionsSyncRequest, AgentSessionsSyncResponse, APITokenListResponse, APITokenRegistrationRequest, APITokenRegistrationResponse, APITokenRevokeResponse, AskResponse, BackupResponse, CalendarSyncRequest, CalendarSyncResponse, CaptureRequest, CaptureResponse, ContextReuseRequest, ContextReuseResponse, DataLifecycleReportResponse, DiagnosticsResponse, GitHubRepositoryDiscoveryRequest, GitHubRepositoryDiscoveryResponse, GitHubSyncRequest, GitHubSyncResponse, GmailSyncRequest, GmailSyncResponse, GoogleDriveSyncRequest, GoogleDriveSyncResponse, GoogleOAuthCompleteRequest, GoogleOAuthCompleteResponse, GoogleOAuthStartRequest, GoogleOAuthStartResponse, GraphResponse, JiraSyncRequest, JiraSyncResponse, JobRunResponse, LinearSyncRequest, LinearSyncResponse, ListResponse, MaintenanceResponse, ManagedOAuthCompleteRequest, ManagedOAuthCompleteResponse, ManagedOAuthStartRequest, ManagedOAuthStartResponse, MCPTokenRegistrationRequest, MCPTokenRegistrationResponse, MemoryQualityResponse, NotionSyncRequest, NotionSyncResponse, ObsidianVaultSyncRequest, ObsidianVaultSyncResponse, OutlookSyncRequest, OutlookSyncResponse, ProductLoopResponse, QueuedCaptureResponse, RaindropSyncRequest, RaindropSyncResponse, ReadwiseSyncRequest, ReadwiseSyncResponse, ReliabilityReportResponse, RepairStorageResponse, SearchResponse, SettingsResponse, SettingsUpdateRequest, SlackChannelDiscoveryRequest, SlackChannelDiscoveryResponse, SlackSyncRequest, SlackSyncResponse, SourceAccountListResponse, SourceAccountRequest, SourceAccountResponse, SourceAccountSyncRequest, SourceAccountSyncResponse, SourceAnalyzeRequest, SourceAnalyzeResponse, SourceImportDeleteResponse, SourceImportRequest, SourceImportResponse, SourceReadinessResponse, StatsResponse, SupportBundleResponse, SyncChangeFeedResponse, SyncCursorListResponse, SyncCursorRequest, SyncCursorResponse, SyncDeviceListResponse, SyncDeviceRequest, SyncDeviceResponse, SyncReceiptListResponse, SyncReceiptRequest, SyncReceiptResponse, VaultRebuildResponse, VectorRebuildResponse, ZoteroSyncRequest, ZoteroSyncResponse
from .models import UserListResponse, UserProvisionRequest, UserProvisionResponse, UserStatusResponse
+from .models import ContextRequest
from .models import CaptureChangePage, SyncIngestRequest, SyncIngestResponse
from .models import SyncDeletionPage, SyncDeletionApplyRequest, SyncDeletionApplyResponse
from .models import GradeAnswerRequest, WouldIRequest, DraftAsMeRequest, GradeTwinPredictionRequest
@@ -61,7 +72,7 @@
from .oidc_registry import OidcError, OidcProviderRegistry
from .ratelimit import TokenBucketRateLimiter
from .sharding import StoreRegistry
-from .storage import BACKEND_VERSION
+from .storage import BACKEND_VERSION, ExportSizeLimitError, UnknownAgentSessionError
from .webauth import (
FAVICON_SVG,
PUBLIC_PAGE_CSP,
@@ -77,6 +88,8 @@
metrics.configure(settings.observability_enabled)
store = StoreRegistry.from_settings(settings)
rate_limiter = TokenBucketRateLimiter(settings.rate_limit_per_minute)
+_credential_evidence_cache: dict[str, Any] = {"key": None, "at": 0.0, "value": None}
+_credential_evidence_cache_lock = threading.Lock()
store.ensure_vault_backfilled(settings.default_user_id)
if settings.mcp_api_key:
store.ensure_mcp_token(
@@ -87,7 +100,38 @@
token_id="tok_local_mcp",
)
-app = FastAPI(title=f"{APP_BRAND} API", version="0.1.0")
+def _stable_operation_id(route: APIRoute) -> str:
+ """Generate IDs from the public HTTP contract, not Python handler names."""
+ methods = "_".join(sorted(method.lower() for method in route.methods))
+ normalized_path = (
+ route.path.strip("/")
+ .replace("/", "_")
+ .replace("{", "by_")
+ .replace("}", "")
+ .replace("-", "_")
+ or "root"
+ )
+ return f"{methods}_{normalized_path}"
+
+
+app = FastAPI(
+ title=f"{APP_BRAND} API",
+ version=BACKEND_VERSION,
+ generate_unique_id_function=_stable_operation_id,
+)
+_bearer_scheme = HTTPBearer(
+ auto_error=False,
+ scheme_name="BearerAuth",
+ description="Origin-bound API, MCP, session, or operator bearer token.",
+)
+
+
+def _authorization_value(
+ credentials: HTTPAuthorizationCredentials | None,
+) -> str | None:
+ if credentials is None:
+ return None
+ return f"{credentials.scheme} {credentials.credentials}"
def _cors_origins() -> list[str]:
@@ -374,17 +418,165 @@ def _global_token_user_id(x_cortex_user: str | None) -> str:
return requested_user or settings.default_user_id
+def _credential_encryption_evidence(
+ *,
+ max_files: int = 20_000,
+ max_file_bytes: int = 2_000_000,
+) -> dict[str, Any]:
+ """Return complete on-disk evidence for the hosted credential release gate.
+
+ Credential files are per vault (one per bucket/user shard), so scanning the
+ small aggregate files is both more complete and cheaper than walking the
+ control-plane user list and materializing every store. Corrupt, unreadable,
+ or over-limit storage fails closed in hosted readiness.
+ """
+ keyring = getattr(store, "keyring", None)
+ evidence: dict[str, Any] = {
+ "enforcement_enabled": bool(settings.require_encrypted_credentials),
+ "keyring_available": bool(keyring is not None and getattr(keyring, "available", False)),
+ "remaining_plaintext": 0,
+ "files_scanned": 0,
+ "scan_complete": True,
+ "unreadable_files": 0,
+ "invalid_records": 0,
+ }
+ shard_root = Path(store.router.shard_root)
+ if not shard_root.exists():
+ return evidence
+ for path in shard_root.rglob("credentials.json"):
+ if evidence["files_scanned"] >= max_files:
+ evidence["scan_complete"] = False
+ break
+ evidence["files_scanned"] += 1
+ try:
+ if path.stat().st_size > max_file_bytes:
+ evidence["invalid_records"] += 1
+ continue
+ payload = json.loads(path.read_text(encoding="utf-8"))
+ except (OSError, UnicodeDecodeError, json.JSONDecodeError):
+ evidence["unreadable_files"] += 1
+ continue
+ if not isinstance(payload, dict):
+ evidence["invalid_records"] += 1
+ continue
+ users = payload.get("users")
+ if not isinstance(users, dict):
+ evidence["invalid_records"] += 1
+ continue
+ for user_credentials in users.values():
+ if not isinstance(user_credentials, dict):
+ evidence["invalid_records"] += 1
+ continue
+ for record in user_credentials.values():
+ if not isinstance(record, dict):
+ evidence["invalid_records"] += 1
+ continue
+ plaintext = record.get("payload")
+ encrypted = record.get("payload_cxe1")
+ # Any retained plaintext copy is a release blocker even if a
+ # valid encrypted envelope is also present.
+ if isinstance(plaintext, dict):
+ evidence["remaining_plaintext"] += 1
+ elif plaintext is not None:
+ evidence["invalid_records"] += 1
+ if encrypted is None:
+ if plaintext is None:
+ evidence["invalid_records"] += 1
+ continue
+ if not isinstance(encrypted, str) or not encrypted:
+ evidence["invalid_records"] += 1
+ continue
+ try:
+ envelope = bytes.fromhex(encrypted)
+ except ValueError:
+ evidence["invalid_records"] += 1
+ continue
+ # Validate the self-describing CXE1 envelope shape without
+ # decrypting or mutating it during a readiness probe.
+ if len(envelope) < 38 or envelope[:4] != b"CXE1":
+ evidence["invalid_records"] += 1
+ continue
+ kek_id_length = envelope[4]
+ minimum_length = 4 + 1 + kek_id_length + 4 + 12 + 16
+ if kek_id_length == 0 or len(envelope) < minimum_length:
+ evidence["invalid_records"] += 1
+ return evidence
+
+
+def _cached_credential_encryption_evidence(*, ttl_seconds: float = 30.0) -> dict[str, Any]:
+ """Bound repeated readiness probes while keeping the release gate fail-closed."""
+ cache_key = str(Path(store.router.shard_root).resolve())
+ now = time.monotonic()
+ with _credential_evidence_cache_lock:
+ if (
+ _credential_evidence_cache["key"] == cache_key
+ and now - float(_credential_evidence_cache["at"]) < ttl_seconds
+ and isinstance(_credential_evidence_cache["value"], dict)
+ ):
+ return dict(_credential_evidence_cache["value"])
+ value = _credential_encryption_evidence()
+ _credential_evidence_cache.update({"key": cache_key, "at": now, "value": dict(value)})
+ return value
+
+
def _hosted_readiness_contract() -> dict[str, Any]:
runtime: dict[str, Any] = {}
- runtime_storage_status = getattr(store, "runtime_storage_status", None)
- if callable(runtime_storage_status):
- runtime["storage"] = runtime_storage_status()
if settings.shard_mode != "local":
+ runtime_storage_status = getattr(store, "runtime_storage_status", None)
+ if callable(runtime_storage_status):
+ runtime["storage"] = runtime_storage_status()
runtime["control_plane"] = store.control_plane_status()
runtime["worker_queue"] = store.hosted_job_health()
+ runtime["credential_encryption"] = _cached_credential_encryption_evidence()
return hosted_readiness_contract(settings, runtime=runtime)
+def _require_local_filesystem_access(operation: str) -> None:
+ """Keep client-supplied paths on the machine that actually owns them.
+
+ Local mode is the desktop/self-hosted trust boundary. In a sharded hosted
+ deployment, accepting a path would read from the API server rather than the
+ authenticated user's computer and could cross tenant boundaries.
+ """
+ if settings.shard_mode != "local":
+ raise HTTPException(
+ status_code=403,
+ detail=f"{operation} is available only in local mode; upload content to hosted {APP_BRAND} instead",
+ )
+
+
+def _require_hosted_connector_origin(source: str, value: str | None) -> None:
+ """Reject credential-bearing requests to user-controlled origins in hosted mode."""
+ if settings.shard_mode == "local" or not str(value or "").strip():
+ return
+ if not is_official_connector_origin(source, value):
+ raise HTTPException(
+ status_code=422,
+ detail=f"Custom {source} API origins are disabled in hosted mode",
+ )
+
+
+def _require_hosted_oauth_token_endpoint(source: str, value: str | None) -> None:
+ if settings.shard_mode == "local" or not str(value or "").strip():
+ return
+ if not is_official_oauth_token_endpoint(source, str(value)):
+ raise HTTPException(
+ status_code=422,
+ detail=f"Custom {source} OAuth token endpoints are disabled in hosted mode",
+ )
+
+
+def _require_hosted_jira_cloud_origin(value: str) -> None:
+ """Hosted Jira currently supports Atlassian Cloud, not arbitrary intranet URLs."""
+ if settings.shard_mode == "local":
+ return
+ if not is_atlassian_cloud_origin(value):
+ raise HTTPException(
+ status_code=422,
+ detail="Hosted Jira connections require an HTTPS *.atlassian.net site URL",
+ )
+
+
def _enforce_rate_limit(user_id: str) -> None:
allowed, retry_after = rate_limiter.check(user_id)
if not allowed:
@@ -427,7 +619,12 @@ def _enforce_memory_quota(user_id: str) -> None:
)
-def auth(request: Request, authorization: str | None = Header(default=None), x_cortex_user: str | None = Header(default=None)) -> str:
+def auth(
+ request: Request,
+ credentials: HTTPAuthorizationCredentials | None = Depends(_bearer_scheme),
+ x_cortex_user: str | None = Header(default=None),
+) -> str:
+ authorization = _authorization_value(credentials)
if not authorization or not authorization.lower().startswith("bearer "):
raise HTTPException(status_code=401, detail=f"Missing or invalid {APP_BRAND} API token")
token = authorization.split(" ", 1)[1].strip()
@@ -452,7 +649,11 @@ def auth(request: Request, authorization: str | None = Header(default=None), x_c
raise HTTPException(status_code=401, detail=f"Missing or invalid {APP_BRAND} API token")
-def mcp_auth(authorization: str | None = Header(default=None), x_cortex_user: str | None = Header(default=None)) -> dict[str, Any]:
+def mcp_auth(
+ credentials: HTTPAuthorizationCredentials | None = Depends(_bearer_scheme),
+ x_cortex_user: str | None = Header(default=None),
+) -> dict[str, Any]:
+ authorization = _authorization_value(credentials)
if not authorization or not authorization.lower().startswith("bearer "):
raise HTTPException(status_code=401, detail=f"Missing or invalid {APP_BRAND} MCP token")
token = authorization.split(" ", 1)[1].strip()
@@ -475,10 +676,57 @@ def mcp_auth(authorization: str | None = Header(default=None), x_cortex_user: st
raise HTTPException(status_code=401, detail=f"Missing or invalid {APP_BRAND} MCP token")
-def admin_auth(authorization: str | None = Header(default=None)) -> bool:
+def tools_auth(
+ credentials: HTTPAuthorizationCredentials | None = Depends(_bearer_scheme),
+ x_cortex_user: str | None = Header(default=None),
+) -> dict[str, Any]:
+ """Authenticate the REST tool adapter with either scoped token audience.
+
+ `/mcp` remains MCP-token-only. The SDK's `/v1/tools/*` adapter is part of
+ the REST surface, so a scoped `cxa_` token must be able to use it alongside
+ search/context without requiring a second client instance and token.
+ """
+ authorization = _authorization_value(credentials)
+ if not authorization or not authorization.lower().startswith("bearer "):
+ raise HTTPException(
+ status_code=401,
+ detail=f"Missing or invalid {APP_BRAND} API or MCP token",
+ )
+ token = authorization.split(" ", 1)[1].strip()
+ if settings.api_key and hmac.compare_digest(token, settings.api_key):
+ user_id = _global_token_user_id(x_cortex_user)
+ return {
+ "user_id": user_id,
+ "token_id": "admin",
+ "label": f"{APP_BRAND} app token",
+ "audience": "admin",
+ "scopes": ["read", "write", "export", "maintenance", "destructive"],
+ "admin": True,
+ }
+ scoped = store.authenticate_mcp_token(token, user_id=x_cortex_user)
+ if scoped is None:
+ scoped = store.authenticate_api_token(token, user_id=x_cortex_user)
+ if scoped:
+ if x_cortex_user and scoped["user_id"] != x_cortex_user:
+ raise HTTPException(
+ status_code=403,
+ detail=f"{APP_BRAND} token does not match requested user",
+ )
+ _enforce_rate_limit(scoped["user_id"])
+ return scoped
+ raise HTTPException(
+ status_code=401,
+ detail=f"Missing or invalid {APP_BRAND} API or MCP token",
+ )
+
+
+def admin_auth(
+ credentials: HTTPAuthorizationCredentials | None = Depends(_bearer_scheme),
+) -> bool:
"""Gate control-plane/admin operations (user provisioning, listing) behind the
operator's global CORTEX_API_KEY. Scoped per-user tokens can never perform
these actions."""
+ authorization = _authorization_value(credentials)
if not authorization or not authorization.lower().startswith("bearer "):
raise HTTPException(status_code=401, detail=f"Missing or invalid {APP_BRAND} admin token")
token = authorization.split(" ", 1)[1].strip()
@@ -573,10 +821,16 @@ def _save_capture_from_values(content: str, source: str, title: str | None, sour
def root() -> Response:
"""Branded landing at the domain root. Account CTAs render only when auth is enabled (the hosted
plane); a plain local backend just shows the download + developer health hints."""
+ create_account_cta = (
+ ' Create an account\n'
+ if _public_signup_enabled()
+ else ""
+ )
account_cta = (
'
\n"
if settings.auth_enabled
else ""
@@ -633,7 +887,8 @@ def _public_footer(links: list[tuple[str, str]]) -> str:
parts = [
f'{html.escape(label)}'
for href, label in links
- if settings.auth_enabled or not href.startswith("/account/")
+ if (settings.auth_enabled or not href.startswith("/account/"))
+ and (_public_signup_enabled() or href != "/account/signup")
]
return '
' + "".join(parts) + "
\n"
@@ -701,11 +956,12 @@ def privacy_page() -> Response:
return _public_html(render_public_page("Privacy Policy · Doppl", body))
-# Plain-language Terms + Privacy that reflect how Doppl ACTUALLY works (local-first storage, per-user
-# encryption, crypto-shred deletion, no data sale). A solid, honest baseline the founder should have
-# reviewed by counsel before broad launch; far better than the 404 the signup page linked to.
+# Plain-language beta disclosures. These are not a substitute for counsel review. Keep claims scoped
+# to behavior that is implemented today; in particular, connector credentials have per-user
+# envelope encryption, while broader hosted memory-content encryption is still being rolled out.
_TERMS_BODY = (
"
Terms of Service
\n"
+ '
Draft — pending legal review before public launch.
\n'
'
These terms cover your use of Doppl (the “Service”), a personal AI-memory '
"app and the account that syncs it. By creating an account you agree to them.
Draft — pending legal review before public launch.
\n'
'
Doppl is built local-first: your memory lives on your device, and your '
"account exists to identify you and sync your own data. Here’s exactly what that means.
\n"
"
What we collect
\n"
@@ -750,27 +1007,31 @@ def privacy_page() -> Response:
"This is stored for you and synced to your devices.\n"
" \n"
"
How your content is protected
\n"
- "
Your memory is stored with per-user isolation and encrypted at rest with a key unique to "
- "your account. We don’t sell your data, we don’t share it with advertisers, and we don’t use it "
- "to train models for other people.
\n"
+ "
Your hosted memory is stored with per-user isolation. Connector credentials are "
+ "encrypted at rest with a key unique to your account; broader encryption of hosted memory "
+ "content is still rolling out. The hosted service can read hosted memory to index and retrieve it. "
+ "We don’t sell your data, share it with advertisers, or use it to train models for other "
+ "people.
\n"
"
Deletion & your control
\n"
- "
You can delete your account from your account page at any time. Deletion "
- "crypto-shreds your encryption keys — making your stored content permanently "
- "unreadable — and removes your data. You can also sign out of individual sessions.
\n"
+ "
You can delete your account from your account page at any time. Deletion destroys the "
+ "per-user key for encrypted connector credentials and removes your hosted memory and derived "
+ "data through the normal deletion path. You can also sign out of individual sessions.
\n"
"
Third parties
\n"
"
If you sign in with Google, GitHub, or Apple, we receive only the basic profile "
"(identifier and email) needed to create your account. Optional payment processing, if you "
"subscribe, is handled by a third-party processor — we never store your card details.
\n"
+ " ",
+ "app.js",
+ ),
+ status_code=503,
+ )
+ try:
+ providers = runtime.oidc.enabled_providers()
+ except Exception:
+ providers = []
+ provider_buttons = _provider_buttons_html(providers, signup=True)
site_key = _turnstile_key()
if site_key:
head_extra = f' \n'
document = _page(
"Create your account · Doppl",
- _signup_body(site_key),
+ _signup_body(site_key, provider_buttons),
"app.js",
head_extra=head_extra,
)
return _html_response(document, csp=_SIGNUP_CSP_TURNSTILE)
- return _html_response(_page("Create your account · Doppl", _signup_body(), "app.js"))
+ return _html_response(
+ _page(
+ "Create your account · Doppl",
+ _signup_body(provider_buttons=provider_buttons),
+ "app.js",
+ )
+ )
@app.get("/account/verify", response_class=HTMLResponse)
def account_verify() -> Response:
diff --git a/backend/requirements.lock b/backend/requirements.lock
new file mode 100644
index 00000000..4f3c6d5f
--- /dev/null
+++ b/backend/requirements.lock
@@ -0,0 +1,531 @@
+#
+# This file is autogenerated by pip-compile with Python 3.12
+# by the following command:
+#
+# make lock-python
+#
+annotated-doc==0.0.5 \
+ --hash=sha256:117bac03a25ede5df5440e855b32d556049ca169ead221505badf432fed4b101 \
+ --hash=sha256:c7e58ce09192557605d8bbd92836d7e1d520ac9580096042c0bfd197efacf1bb
+ # via fastapi
+annotated-types==0.8.0 \
+ --hash=sha256:13b2beaad985e05e2d6407ee4c4f35590b11f8d693a258a561055cac8f64cab7 \
+ --hash=sha256:f072f4d804ea359e4eaf198b1af7a8b0943881a87f31bb764f8bf219bb9419e0
+ # via pydantic
+anthropic==0.120.2 \
+ --hash=sha256:0f0bc2b381dc0eb41c8d886b815d79c2041cd2374f83aed36f574b6dc9c579c1 \
+ --hash=sha256:9722efc10c27a30a69f5338ddacdb35bc6a64297a4e4ba729bf83af873d5fb3a
+ # via -r backend/requirements.txt
+anyio==4.14.2 \
+ --hash=sha256:9f505dda5ac9f0c8309b5e8bd445a8c2bf7246f3ce950121e45ea15bc41d1494 \
+ --hash=sha256:cfa139f3ed1a23ee8f88a145ddb5ac7605b8bbfd8592baacd7ce3d8bb4313c7f
+ # via
+ # anthropic
+ # httpx
+ # starlette
+argon2-cffi==25.1.0 \
+ --hash=sha256:694ae5cc8a42f4c4e2bf2ca0e64e51e23a040c6a517a85074683d3959e1346c1 \
+ --hash=sha256:fdc8b074db390fccb6eb4a3604ae7231f219aa669a2652e0f20e16ba513d5741
+ # via -r backend/requirements.txt
+argon2-cffi-bindings==25.1.0 \
+ --hash=sha256:1db89609c06afa1a214a69a462ea741cf735b29a57530478c06eb81dd403de99 \
+ --hash=sha256:1e021e87faa76ae0d413b619fe2b65ab9a037f24c60a1e6cc43457ae20de6dc6 \
+ --hash=sha256:21378b40e1b8d1655dd5310c84a40fc19a9aa5e6366e835ceb8576bf0fea716d \
+ --hash=sha256:2630b6240b495dfab90aebe159ff784d08ea999aa4b0d17efa734055a07d2f44 \
+ --hash=sha256:3c6702abc36bf3ccba3f802b799505def420a1b7039862014a65db3205967f5a \
+ --hash=sha256:3d3f05610594151994ca9ccb3c771115bdb4daef161976a266f0dd8aa9996b8f \
+ --hash=sha256:473bcb5f82924b1becbb637b63303ec8d10e84c8d241119419897a26116515d2 \
+ --hash=sha256:5acb4e41090d53f17ca1110c3427f0a130f944b896fc8c83973219c97f57b690 \
+ --hash=sha256:5d588dec224e2a83edbdc785a5e6f3c6cd736f46bfd4b441bbb5aa1f5085e584 \
+ --hash=sha256:6dca33a9859abf613e22733131fc9194091c1fa7cb3e131c143056b4856aa47e \
+ --hash=sha256:7aef0c91e2c0fbca6fc68e7555aa60ef7008a739cbe045541e438373bc54d2b0 \
+ --hash=sha256:84a461d4d84ae1295871329b346a97f68eade8c53b6ed9a7ca2d7467f3c8ff6f \
+ --hash=sha256:87c33a52407e4c41f3b70a9c2d3f6056d88b10dad7695be708c5021673f55623 \
+ --hash=sha256:8b8efee945193e667a396cbc7b4fb7d357297d6234d30a489905d96caabde56b \
+ --hash=sha256:a1c70058c6ab1e352304ac7e3b52554daadacd8d453c1752e547c76e9c99ac44 \
+ --hash=sha256:a98cd7d17e9f7ce244c0803cad3c23a7d379c301ba618a5fa76a67d116618b98 \
+ --hash=sha256:aecba1723ae35330a008418a91ea6cfcedf6d31e5fbaa056a166462ff066d500 \
+ --hash=sha256:b0fdbcf513833809c882823f98dc2f931cf659d9a1429616ac3adebb49f5db94 \
+ --hash=sha256:b55aec3565b65f56455eebc9b9f34130440404f27fe21c3b375bf1ea4d8fbae6 \
+ --hash=sha256:b957f3e6ea4d55d820e40ff76f450952807013d361a65d7f28acc0acbf29229d \
+ --hash=sha256:ba92837e4a9aa6a508c8d2d7883ed5a8f6c308c89a4790e1e447a220deb79a85 \
+ --hash=sha256:c4f9665de60b1b0e99bcd6be4f17d90339698ce954cfd8d9cf4f91c995165a92 \
+ --hash=sha256:c87b72589133f0346a1cb8d5ecca4b933e3c9b64656c9d175270a000e73b288d \
+ --hash=sha256:d3e924cfc503018a714f94a49a149fdc0b644eaead5d1f089330399134fa028a \
+ --hash=sha256:da0c79c23a63723aa5d782250fbf51b768abca630285262fb5144ba5ae01e520 \
+ --hash=sha256:e2fd3bfbff3c5d74fef31a722f729bf93500910db650c925c2d6ef879a7e51cb
+ # via argon2-cffi
+certifi==2026.7.22 \
+ --hash=sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775 \
+ --hash=sha256:741e2c3b351ddf169a738da9f2c048608ff7f2c5cc02f1ebc6b118bb090d5d55
+ # via
+ # httpcore
+ # httpx
+cffi==2.1.0 \
+ --hash=sha256:02cb7ff33ded4f1532476731f89ede53e2e488a8e6205515a82144246ffa7dcc \
+ --hash=sha256:03e9810d18c646077e501f661b682fbf5dee4676048527ca3cffe66faa9960dd \
+ --hash=sha256:0520e1f4c35f44e209cbbb421b67eec42e6a157f59444dfb6058874ff3610e5d \
+ --hash=sha256:0582a58f3051372229ca8e7f5f589f9e5632678208d8636fea3676711fdf7fe5 \
+ --hash=sha256:0611e7ebf90573a535ebdc33ae9da222d037853983e13359f580fab781ca017f \
+ --hash=sha256:0a42c688d19fca6e095a53c6a6e2295a5b050a8b289f109adab02a9e61a25de6 \
+ --hash=sha256:0a96b74cda968eebbad56d973efe5098974f0a9fb323865bf99ea1fd24e3e64c \
+ --hash=sha256:10537b1df4967ca26d21e5072d7d54188354483b91dc75058968d3f0cf13fbda \
+ --hash=sha256:11b3fb55f4f8ad92274ed26705f65d8f91457de71f5380061eb6d125a768fecd \
+ --hash=sha256:15faec4adfff450819f3aee0e2e02c812de6edb88203aa58807955db2003472a \
+ --hash=sha256:164bff1657b2a74f0b6d54e11c9b375bc97b931f2ca9c43fcf875838da1570dd \
+ --hash=sha256:1854b724d00f6654c742097d5387569021be12d3a0f770eae1df8f8acfcc6acd \
+ --hash=sha256:19c54ac121cad98450b4896fa9a43ee0180d57bc4bc911a33db6cab1efab6cd3 \
+ --hash=sha256:1b96bfe2c4bd825681b7d311ad6d9b7280a091f43e8f63da5729638083cd3bfb \
+ --hash=sha256:1e9f50d192a3e525b15a75ab5114e442d83d657b7ec29182a991bc9a88fd3a66 \
+ --hash=sha256:1ff3456eab0d889592d1936d6125bbfbc7ae4d3354a700f8bd80450a66445d4d \
+ --hash=sha256:2282cd5e38aa8accd03e99d1256af8411c84cdbee6a89d841b563fdbd1f3e50f \
+ --hash=sha256:276f20fffd7b396e12516ba8edf9509210ac248cbbc5acbc39cd512f9f59ebe6 \
+ --hash=sha256:2b71d409cccee78310ab5dec549aed052aaea483346e282c7b02362596e01bb0 \
+ --hash=sha256:2e9dabb9abcb7ad15938c7196ad5c1718a4e6d33cc79b4c0209bdb64c4a54a5c \
+ --hash=sha256:30b65779d598c370374fefabf138d456fd6f3216bfa7bedfab1ba82025b0cd93 \
+ --hash=sha256:33eb1ad83ebe8f313e0df035c406227d55a79456704a863fad9842136af5ad7d \
+ --hash=sha256:35aaea0c7ee0e58a5cd8c2fd1a48fdf7ece0d2699b7ecdda08194e9ce5dd9b3d \
+ --hash=sha256:3681e031db29958a7502f5c0c9d6bbc4c36cb20f7b104086fa642d1799631ff8 \
+ --hash=sha256:379de10ce1ba048b1448599d1b37b24caee16309d1ac98d3982fc997f768700b \
+ --hash=sha256:37f525a7e7e50c017fdebe58b787be310ad59357ae43a053943a6e1a6c526001 \
+ --hash=sha256:3b926723c13eba9f81d2ef3820d63aeceec3b2d4639906047bf675cb8a7a500d \
+ --hash=sha256:3d7f118b5adbfdfead90c25822690b02bc8074fba949bb7858bec4ebd55adb43 \
+ --hash=sha256:46b1c8db8f6122420f32d02fffb924c2fe9bc772d228c7c711748fff56aabb2b \
+ --hash=sha256:47ff3a8bfd8cb9da1af7524b965127095055654c177fcfc7578debcb015eecd0 \
+ --hash=sha256:4d433a51f1870e43a13b6732f92aaf540ff77c2015097c78556f75a2d6c030e0 \
+ --hash=sha256:4f26194e3d95e06501b942642855aed4f953d55e95d7d01b7c4483db3ecff458 \
+ --hash=sha256:510aeeeac94811b138077451da1fb18b308a5feab47dd2b603af55804155e1c8 \
+ --hash=sha256:5972433ad71a9e46516584ef60a0fda12d9dc459938d1539c3ddecf9bdc1368d \
+ --hash=sha256:5ecbd0499275d57506d397eebe1981cee87b47fcd9ef5c22cab7ed7644a39a94 \
+ --hash=sha256:6274dcb2d15cef48daa73ed1be5a40d501d74dccd0cd6db364776d12cb6ba022 \
+ --hash=sha256:63960549e4f8dc41e31accb97b975abaecfc44c03e396c093a6436763c2ea7db \
+ --hash=sha256:64c753a0f87a256020004f37a1c8c02c480e725f910f0b2a0f3f07debd1b2479 \
+ --hash=sha256:6af371f3767faeffc6ac1ef57cdfd25844403e9d3f476c5537caee499de96376 \
+ --hash=sha256:6ca4919c6e4f89aa99c42510b42cf54596892c00b3f9077f6bdd1505e24b9c8d \
+ --hash=sha256:6d194185eabd279f1c05ebe3504265ddfc5ad2b58d0714f7db9f01da592e9eb6 \
+ --hash=sha256:702c436735fbe99d59ada02a1f65cfc0d31c0ee8b7290912f8fbc5cd1e4b16c3 \
+ --hash=sha256:716ff8ec22f20b4d988b12884086bcef0fc99737043e503f7a3935a6be99b1ea \
+ --hash=sha256:762f99479dcb369f60ab9017ad4ab97a36a1dd7c1ee5a3b15db0f4b8659120cd \
+ --hash=sha256:7762faa47e8ff7eb80bd261d9a7d8eea2d8baa69de5e95b70c1f338bbe712f02 \
+ --hash=sha256:78474632761faa0fb96f30b1c928c84ebcf68713cbb80d15bab09dfe61640fde \
+ --hash=sha256:799416bae98336e400981ff6e532d67d5c709cfb30afb79865a1315f94b0e224 \
+ --hash=sha256:7d034dcffa09e9a46c93fa3a3be402096cb5354ac6e41ab8e5cc9cd8b642ad76 \
+ --hash=sha256:7d28dff1db6764108bc30788d85d61c876beff416d9a49cb9dd7c5a9f34f5804 \
+ --hash=sha256:7d3538f9c0e50670f4deb93dbb696576e60590369cae2faf7de681e597a8a1f1 \
+ --hash=sha256:7d5980a3433d4b71a5e120f9dd551403d7824e31e2e67124fe2769c404c06913 \
+ --hash=sha256:7ea6b3e2c4250ff1de21c630fe72d0f63eb95c2c32ffbf64a358cf4a8836d714 \
+ --hash=sha256:86cf8755a791f72c85dc287128cc62d4f24d392e3f1e15837245623f4a33cccc \
+ --hash=sha256:88023dfe18799507b73f1dbb0d14326a17465de1bc9c9c7655c22845e9ddc3a2 \
+ --hash=sha256:89095c1968b4ba8285840e131bf2891b09ae137fe2146905acae0354fbce1b5e \
+ --hash=sha256:8d35c139744adb3e727cd51b1a18324bbe44b8bd41bf8322bca4d41289f48eda \
+ --hash=sha256:8e74a6135550c4748af665b1b1118b6aab33b1fc6a16f9aff630af107c3b4512 \
+ --hash=sha256:8f9ec95b8a043d3dfbc74d9abc6f7baf524dd27a8dc160b0a32ff9cdab650c28 \
+ --hash=sha256:90bec57cf82089383bd06a605b3eb8daebf7e5a668520beaf6e327a83a947699 \
+ --hash=sha256:95f2954c2c9473d892eca6e0409f3568b37ab62a8eedb122461f73cc273476e3 \
+ --hash=sha256:961be50688f7fba2fa65f63712d3b9b341a22311f5253460ce933f52f0de1c8c \
+ --hash=sha256:98fff996e983a36d3aa2eca83af40c5821202e7e6f32d13ae94e3d2286f10cfe \
+ --hash=sha256:9b8f0f26ca4e7513c534d351eca551947d053fac438f2a04ac96d882909b0d3a \
+ --hash=sha256:9d72af0cf10a76a600a9690078fe31c63b9588c8e86bf9fd353f713c84b5db0f \
+ --hash=sha256:9d8272c0e483b024e1b9ad029821470ed8ec65631dbd90217469da0e7cd89f1c \
+ --hash=sha256:a016194dbe13d14ee9556e734b772d8d67b947092b268d757fd4290e3ba2dfc2 \
+ --hash=sha256:a5781494d4d400a3f47f8f1da94b324f6e6b440a53387774002890a2a2f4b50f \
+ --hash=sha256:a95b05f9baf29b91171b3a8bd2020b028835243e7b0ff6bb23e2a3c228518b1b \
+ --hash=sha256:aa7a1b53a2a4452ada2d1b5dade9960b2522f1e61293a811a077439e39029565 \
+ --hash=sha256:ac0f1a2d0cfa7eea3f2aaf006ab6e70e8feeb16b75d65b7e5939982ca2f11056 \
+ --hash=sha256:af5e2915d41fe6c961694d7bfdc8562942638200f3ce2765dfb8b745cf997629 \
+ --hash=sha256:b6422532152adf4e59b110cb2808cee7a033800952f5c036b4af047ee43199e7 \
+ --hash=sha256:b65f590ef2a44640f9a05dbb548a429b4ade77913ce683ac8b1480777658a6c0 \
+ --hash=sha256:ba00f661f8ba35d075c937174e27c2c421cec3942fd2e0ea3e66996757c0fdd9 \
+ --hash=sha256:bccbbb5ee76a61f9d99b5bf3846a51d7fca4b6a732fe46f89295610edaf41853 \
+ --hash=sha256:bf01d8c84cbea96b944c73b22182e6c7c432b3475632b8111dbfdc95ddad6e13 \
+ --hash=sha256:bf5c6cf48238b0eb4c086978c492ad1cbc22373fc5b2d7353b3a598ce6db887a \
+ --hash=sha256:c16914df9fb7f500e440e6875fa23ff5e0b31db01fa9c06af98d59a91f0dc2e4 \
+ --hash=sha256:c351efb95e832a853a29361675f33a7ce53de1a109cd73fd47af0712213aa4ce \
+ --hash=sha256:c4165821e131d6d4ca444347c2b694e2311bcfa3fe5a861cc72968f28867beac \
+ --hash=sha256:c5f5df567f6eb216de69be06ce55c8b714090fae02b18a3b40da8163b8c5fa9c \
+ --hash=sha256:c941bb58d5a6e1c3892d86e42927ed6c180302f07e6d395d08c416e594b98b46 \
+ --hash=sha256:c97f080ea627e2863524c5af3836e2270b5f5dfff1f104392b959f8df0c5d384 \
+ --hash=sha256:cb96698e3c7413d906ce83f8ffd245ec1bd94707541f299d0ce4d6b0193e982b \
+ --hash=sha256:cbb7640ce37159548d2147b5b8c241f962143d4c71231431820783f4dc78f210 \
+ --hash=sha256:cdf2448aab5f661c9315308ec8b93f4e8a1a67a3c733f8631067a2b67d5913dc \
+ --hash=sha256:d2117334c3af3bdcb9a88522b844a2bdb5efdc4f71c6c822df55486ae1c3347a \
+ --hash=sha256:d53d10f7da99ae46f7373b9150393e9c5eab9b224909982b43832668de4779f5 \
+ --hash=sha256:d9fafc5aa2e2a39aaf7f8cc0c1f044a9b07fca12e558dca53a3cc5c654ad67a7 \
+ --hash=sha256:db3eb7d46527159a878ec3460e9d40615bc25ba337d477db681aea6e4f05c5d2 \
+ --hash=sha256:dbf7c7a88e2bac086f06d14577332760bdeecc42bdec8ac4077f6260557d9326 \
+ --hash=sha256:df2b82571a1b30f58a87bf4e5a9e78d2b1eff6c6ce8fd3aa3757221f93f0863f \
+ --hash=sha256:df92f2aba50eb4d96718b68ef76f2e57a57b54f2fa62333496d16c6d585a85ca \
+ --hash=sha256:eb4e8997a49aa2c08a3e43c9045d224448b8941d88e7ac163c7d383e560cbf98 \
+ --hash=sha256:efc1cdd798b1aaf39b4610bba7aad28c9bea9b910f25c784ccf9ec1fa719d1f9 \
+ --hash=sha256:f146d154428a2523f9cc7936c02353c2459b8f6cf07d3cd1ee1c0a611109c5d5 \
+ --hash=sha256:f5bce581e6b8c235e566a14768a943b172ada3ed73537bb0c0be1edee312d4e7 \
+ --hash=sha256:f9912624a0c0b834b7520d7769b3644453aabc0a7e1c839da7359f050750e9bc \
+ --hash=sha256:fb62edb5bb52cca65fab91a63afa7561607120d26090a7e8fda6fb9f064726da \
+ --hash=sha256:ff067a8d8d880e7809e4ac88eb009bb848870115317b306666502ccad30b147f
+ # via
+ # argon2-cffi-bindings
+ # cryptography
+click==8.4.2 \
+ --hash=sha256:9a6cea6e60b17ebe0a44c5cc636d94f09bd66142c1cd7d8b4cd731c4917a15f6 \
+ --hash=sha256:e6f9f66136c816745b9d65817da91d61d957fb16e02e4dcd0552553c5a197b76
+ # via uvicorn
+cryptography==49.0.0 \
+ --hash=sha256:026ac7423e6fa66872d3bf889be5974507da3944f866f704fa200eadacd00001 \
+ --hash=sha256:07cab27cc7b7e0fd28e5e26bb9eeedde5c135c868b46de4a27845abe94af6122 \
+ --hash=sha256:084ef1af862eb07ec46d25f68689f2102a9fc0e05ce7b80f14f5fe51e4eef0f6 \
+ --hash=sha256:0b82e28ee398a386f0807bba7884d30f25218855690f45115831bcce5d90822c \
+ --hash=sha256:0e959b578856a3924bc0cbb710fc12c387b9412a951389f3ca61704a9e25f325 \
+ --hash=sha256:0f21641cf4b30fca7aee061ced0ec7ad7b073518088b7c9969a297c0ae796c69 \
+ --hash=sha256:196ecd6a36e4e9aa10270393bb98d8df88fccee0bf1e5128b91ae4eb4375896d \
+ --hash=sha256:2400ef9c9e2299a25614eb1dea3db54a69b1349efd043bfac9c67630d136df36 \
+ --hash=sha256:28d8b15e6275f12c8a207dc309dfa957903c927d08d0cc937ee3f63f200693cc \
+ --hash=sha256:2afe9051da7ae7bd5905da5a949280c7d2bb75682e188f650a9d0f2756b834c6 \
+ --hash=sha256:2eda353d8a27bcbcaa4cbed18994a74ab4d19a2ca897db188ea269ab9b71419b \
+ --hash=sha256:32703d93296f5c1f4b53349ad3a250c2cae0fdecd3a3dd5d47e616d8d616af27 \
+ --hash=sha256:33cd0565932807baddb67b96dbee92f2c374b5c89dee09fd74079aeb8c8dba61 \
+ --hash=sha256:35b151772baff2c74cba7fa290ceaff4c3b11c0c881eb93eb5dbc05a7cfbba18 \
+ --hash=sha256:36d1709f992593689b45bda411498d62c6e365f2ca00b84657d4dadd24de16db \
+ --hash=sha256:42b0684e0e40cf26122427802486f6d93aea593612603a94fbf260c7eb1e9c1b \
+ --hash=sha256:4ae387c9cb68ea569ca17e490d66d8142b81c3cc814bf179974b7d146e490bbb \
+ --hash=sha256:53ecee2e23f7169b6117e99fc8a944e5e50f79e69758a83b52a00cb98ab2b2d2 \
+ --hash=sha256:66ec79c3904820572d7e987abdf304281f141d37ad9a489b8e97066e7b9b6459 \
+ --hash=sha256:67e1d20ad9ef3a563c59ef22e7a8a0b8210bd26604369ea4a30a7c66aefe504e \
+ --hash=sha256:6f2debedf9ca60cf1d5bd466475638af5130f89965605cd818484d19987d3a21 \
+ --hash=sha256:6fc361c34fb6aac015ce19435876635e5c6d21db31998b0920f675f131e043b8 \
+ --hash=sha256:73a205dce83953d131a4aa1e0fd917a2fd1c5b1eef251e9d7152efefcbf5caf7 \
+ --hash=sha256:7abcee80084cda3f7691f3eb1ce480d8df49cec637b429aa35986c1de71738aa \
+ --hash=sha256:8c25ceb16df5b9435f3f6a9829204985b0e0cbee3b48aacd432c7d2c850b44d9 \
+ --hash=sha256:966fe0e9c67490071f14c0d2b1cb2dfb3023c5ce39457343931415f08382f2db \
+ --hash=sha256:9e82dcc8e56052715fb18b2429e3bca4823b1629136a2084fc45a9a5cecb9b64 \
+ --hash=sha256:b20133d204d2bb56ba047642199603876c872026ca53e79c35b83772ab2cc505 \
+ --hash=sha256:b39efa323140595abd3ecca8529d321ae50f55f3aa3ba9cc81ea56a6011953d5 \
+ --hash=sha256:b47db11c2c3525083296069b98ac5221907455e989ae0c2e3008bde851921615 \
+ --hash=sha256:b87e65d263b3e5d3bb92a57e2a6638e2f31110fa7aa890c7b2dbba42248d0a3f \
+ --hash=sha256:b970c6da94d5bb18629db453d14f2a1300f6bf59b61e9b82377931ef95504866 \
+ --hash=sha256:be9fcb48a55f023493482827d4f459bd263cc20efde64f204b97c123201850c6 \
+ --hash=sha256:c2bc30226390d60ea19d9f82b19db005fe0452154a23c1c410c12ea801e43561 \
+ --hash=sha256:c83782480a4a9da4d0feb51950131ba32e12e70813848b3343f6e18c28a66838 \
+ --hash=sha256:cbc77da8c523d5abd028635ba850a6966fcee2c82e2bf65a41d1d8afe0f98be9 \
+ --hash=sha256:ccac2bfebc306b862133e3bb71f3f6ee8bb525240089b2d952e4144b3a6d5da7 \
+ --hash=sha256:d0527ce944105f257f605a827d6ebead966c752038b6e8656abb9c5edee6fc68 \
+ --hash=sha256:d8ecde755e2e91bf773fc94e8c9d730cd7f2007004cb492263a794ec3899a1c8 \
+ --hash=sha256:e3fb64c420688e5319ae25113a354015abbd8dffbfbc41781a1ea66fc7622ac3 \
+ --hash=sha256:e5dfc1e64de5677cec922ffa8da89c546d0415bf6efdf081842e5d44c84e1f0e \
+ --hash=sha256:ec5e529fb80935c94fe7b729f9972b50e351a0e6b50aa294fd5cabb109fcc29a \
+ --hash=sha256:f37d847238971164fdbc68ade6f6574aecc9c0af714190e2083429ff68f4ce9d \
+ --hash=sha256:f78ff2c9ed8dc2d036b0f4d640e22522213d047c1b14e61205a7e55c80a494d4 \
+ --hash=sha256:f89660a348f4f78a92366240a61404e337586ef7f5909a2fef59ca88ef505493 \
+ --hash=sha256:fc1e275c2f1d97b1a6450b8b0ea3ebfa6e087a611c2b26cb2404d48588abab7b
+ # via -r backend/requirements.txt
+distro==1.9.0 \
+ --hash=sha256:2fa77c6fd8940f116ee1d6b94a2f90b13b5ea8d019b98bc8bafdcabcdd9bdbed \
+ --hash=sha256:7bffd925d65168f85027d8da9af6bddab658135b840670a223589bc0c8ef02b2
+ # via anthropic
+docstring-parser==0.18.0 \
+ --hash=sha256:292510982205c12b1248696f44959db3cdd1740237a968ea1e2e7a900eeb2015 \
+ --hash=sha256:b3fcbed555c47d8479be0796ef7e19c2670d428d72e96da63f3a40122860374b
+ # via anthropic
+fastapi==0.141.1 \
+ --hash=sha256:bfb91aa2d334c61cb35ba9a116fc123b3d3df31640b801cf57a7a78ec3f603b3 \
+ --hash=sha256:e8822fc40db1e1858054d7a949a888695bc9bdce70139178e33bd2871a453ca1
+ # via -r backend/requirements.txt
+h11==0.16.0 \
+ --hash=sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1 \
+ --hash=sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86
+ # via
+ # httpcore
+ # uvicorn
+httpcore==1.0.9 \
+ --hash=sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55 \
+ --hash=sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8
+ # via httpx
+httpx==0.28.1 \
+ --hash=sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc \
+ --hash=sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad
+ # via anthropic
+idna==3.18 \
+ --hash=sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2 \
+ --hash=sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848
+ # via
+ # anyio
+ # httpx
+jiter==0.16.0 \
+ --hash=sha256:0542a7189c26920778658fc8fcf2af8bae05bae9924577f71804acef37996536 \
+ --hash=sha256:0758ab7747a984797cf048e8eedea1d8ef39d7994b25611daf5b48fc903e8873 \
+ --hash=sha256:0df91907609837f33341b8e6fe73b95991fdaa57caf1a0fbd343dffe826f386f \
+ --hash=sha256:0e2e9efbe042210df657bade597f66d6d75723e3d8f45a12ea6d8167ff8bbce3 \
+ --hash=sha256:0fa25b09b13075c46f5bc174f2690525a925a4fc2f7c82969a2bbabff22386ce \
+ --hash=sha256:10438939205546132189c8e74a2d536a707841f3a25cd7c74ee91fe503407a26 \
+ --hash=sha256:12288303c9844e61e1651d02a9a6f6633e47d39f897d6991d1427161ce6b746e \
+ --hash=sha256:17ca7fae79f6d99cd9a042b75f917eaada7b895cfc7dd2ee3a16089dcaec7a85 \
+ --hash=sha256:1878349266f8ee36ecb1375cc5ba2f115f35fd9f0a1a4119e725e379126647f7 \
+ --hash=sha256:1a0f05e229edb29e68cdd0ccb83cea13b64263416120cf943767a6fd72e6787f \
+ --hash=sha256:2577196f4474ef3fc4779a088a23b0897bbf86f9ea3679c372d45b8383b43207 \
+ --hash=sha256:26798522707abb47d767db536e4148ceac1b14446bf028ee85e579a2e043cfe5 \
+ --hash=sha256:26d122613ada2b708eb714695446f40fce5bdf2edb4b02116dec62faa62dfab3 \
+ --hash=sha256:2a0d46ef67cc58d906a6132dd3040ca70ae4f0b0d7c9c052fe432c658a69b3f6 \
+ --hash=sha256:2a8e9e39cf083016137aa5cadafe3188adc2ba6ba1fbf1e5d18889ad3e9ad056 \
+ --hash=sha256:2c842cbf374a8daf50b2c04212995bee34ca2ac2cdc29a901b4cdb072c9c4131 \
+ --hash=sha256:2ed5738ae4af18271a51a528b8811b0cbfa4a1858de9d83359e4169855d6a331 \
+ --hash=sha256:3126a5dbad56401989ac769aca0cb56005bfb3e2366eea0ca99d1a91c3c1ee03 \
+ --hash=sha256:31aaee5b80f672c1dc21272bcfb9cbdcfc1ea04ff50f00ed5af500b80c44fa93 \
+ --hash=sha256:3a23f0e4f957e1be65752d2dfac9a5a06b1917af8dc85deb639c3b9d02e31290 \
+ --hash=sha256:3b21b412b899fd8bd51a3046934b59a3bb068b79f70a5c6010053ac77cc53f0c \
+ --hash=sha256:3c1fd2dbe1b0af19e987f03fe66c5f5bd105a2229c1aff4ab14890b24f41d21a \
+ --hash=sha256:3f4d9e473a5ce7d27fef8b848df4dc16e283893d3f53b4a585e72c9595f3c284 \
+ --hash=sha256:3fb7bc819187b56dc48aa5c833aaf92257da8e07efdb9306156667bd2eeb491c \
+ --hash=sha256:41977aa5654023948c2dae2a81cbf9c43343954bef1cd59a154dd15a4d84c195 \
+ --hash=sha256:44d019fa8cdaf89bf29c71b39e3712143fdd0ac76725c6ef954f9957a5ea8730 \
+ --hash=sha256:46add52f4ad47a08bfb1219f3e673da972191489a33016edefdb5ea55bfa8c48 \
+ --hash=sha256:49060fd70737fad59d33ba9dcc0d83247dc9e77187de26053a19c16c9f32bd69 \
+ --hash=sha256:491e7d072a253b156fff46b78bceac4652a697aa8d7082c9c18c03d7b7917d24 \
+ --hash=sha256:4a1d68ff7ca1d3b5dee20a97a3decda7d5f15003823bf6d140c81f8561d3bc5c \
+ --hash=sha256:51d7b836acb0108d7c77df1742332cac2a1fa04a74d6dacec46e7091f0e91274 \
+ --hash=sha256:560b2cf3fb03240cd34f27409a238547488708f05b7c3924f571a60422251ec7 \
+ --hash=sha256:561926e0573ffe4a32498420a76d64b16c513e1ab413b9d28158a8764ac701e5 \
+ --hash=sha256:57b37fc887a32d44798e4d8ebfa7c9683ff3da1d5bf38f08d1bb3573ccb39106 \
+ --hash=sha256:5a71d0d2014c3275043e1170bf3d4e771493cb0dcf07be54c567155f4d8ee64b \
+ --hash=sha256:5ab4f50ff971b611d656554ea10b75f80097392c827bc32923c6eeb6386c8b00 \
+ --hash=sha256:5af7780e4a26bd7d0d989592bf9ef12ebf806b74ab709223ecca37c749872ea9 \
+ --hash=sha256:5cf109d010b4b05a105afb3d43be36a21322d345ad3111e13d15f680afef0e5b \
+ --hash=sha256:5ed466aee31294d7cdcd4d37dfe5c42c97bc29d9a5f00eacf24504358309cb9b \
+ --hash=sha256:616e89e008a93c01104161c75b4988e58716b01d62307ebfe161e52a56d2a818 \
+ --hash=sha256:62c1b7fe1f77925acf5af68b6140b8810fa87dfd4dc0a9c8568ec2fa2a10429c \
+ --hash=sha256:63efadc657488f45db1c676d81e704cac2abf3fdb892def1faea61db053127e2 \
+ --hash=sha256:63ffdbdae7d4499f4cda14eadc12ddcabef0fc0c081191bdc2247489cb698077 \
+ --hash=sha256:64c0203212098470032aabcde9356fc168f377aade3e43def61dfe17e92f2037 \
+ --hash=sha256:64d613743df53199b1aa256a7d328340da6d7078aac7705a7db9d7a791e9cfd2 \
+ --hash=sha256:659039cc50b5addcc35fcc87ae2c1833b7c0a8e5326ef631a75e4478447bcf84 \
+ --hash=sha256:6722bcef4ffc86c835574b1b2fac6b33b9fb4a889c781e67950e891591f3c55a \
+ --hash=sha256:67c3bc1760f8c99d805dcab4e644027142a53b1d5d861f18780ebdbd5d40b72a \
+ --hash=sha256:67fddeda1688f0cce2d2ae83ccf8a80f79936f2d2997d6cc2261f82fdb54a4d3 \
+ --hash=sha256:6edb63a46e65a82c26800a868e49b2cac30dd5a4218b88d74bc2c848c8ad60bb \
+ --hash=sha256:70a490b55634dc0d2606ce8a8e01b1d62459011beb368d15d76e1eaf62460e3d \
+ --hash=sha256:710cc51d4ebdcd3c1f70b232c1db1ea1344a075770422bbd4bede5708335acbe \
+ --hash=sha256:741eed508c233a76313a1c7b001f8f21b82f14327e9196ae8bd29a2cc164ae84 \
+ --hash=sha256:7b24c3492c5f4f84a37946ad9cf504910cf6a782d6a4e0689b6673c5894b4a1c \
+ --hash=sha256:7c9610fd25ebccb43fca584136f5c2fbb26802447eccd430dfdbab95a0fd5126 \
+ --hash=sha256:850ccb1d7eedb4200f4014b1c0e8a577de114fc3cd88faad646dcc9bc4bb12ad \
+ --hash=sha256:8597d23c87f59294f83bcb6229b9ed1fccee13dbba967b46930d2f1759466fee \
+ --hash=sha256:8d031aabecc4f1b6276adfb42e3aabb77c89d468bf616600e8d3a11328929053 \
+ --hash=sha256:8d30a4a1c87713060c8d1cc59a7b6c8fb6b8ef0a6900368014c76c87922a2929 \
+ --hash=sha256:8fb8de1e23a0cb2a7f53c335049c7b72b6db41aa6227cdcc0972a1de5cb39450 \
+ --hash=sha256:8fc4d94713c4697347e38faf7d6ef91547c142219bdcfc7220c4870879974244 \
+ --hash=sha256:96e38eea538c8ddf853a35727c7be0741c76c13f04148ac5c116222f50ece3b3 \
+ --hash=sha256:9acf1b2faec82d998811ecce7ae84d9005e53410773e9d37d61cdc424ba4581b \
+ --hash=sha256:9c8a956fd72c2cf1e730d01ea080341f13aa0a97a4a33b51abebe725b7ae9ca9 \
+ --hash=sha256:9ec553a99b0987efd7a3645a1a825cf29c224e494db267a83369fcc8da9aeda5 \
+ --hash=sha256:a111256a7193bea0759267b10385e5870949c239ed7b6ddbaaf57573edb38734 \
+ --hash=sha256:a32d2027a9fa67f109ff245a3252ece3ccc32cc56703e1deab6cc846a59e0585 \
+ --hash=sha256:a5c54ef4ff776d9675837ef535b3308d6e31c208d43ebc44a0f7ab8a208c68f7 \
+ --hash=sha256:adbb8edeadd431bc4477879d5d371ece7cb1334486584e0f252656dd7ffada29 \
+ --hash=sha256:adff21bc78edfe086c15eb495b900306076de378dc2337c132401fc39bd79c91 \
+ --hash=sha256:b1e7923093a376d93c6eb507c77045ae258d689ba577392846a1b3f10d0b09a9 \
+ --hash=sha256:b2c61484666ad42726029af0c00ef4541f0f3b5cdc550221f56c2343208018ee \
+ --hash=sha256:b42e9ff5376819c053da25809a8d4b6fa6e473b4856ebe42e298ac958be3d7f9 \
+ --hash=sha256:b72d0b2990ca754a9102779ac98d8597b7cb31678958562214a007f909eab78e \
+ --hash=sha256:b7dc85ea77d4abbae8bad0d3538678aedee75bceec4e2f6c8dfb1c74772e5aa5 \
+ --hash=sha256:b8a3f9a6008048fe9def7bf465180564a6e458047d2ce499149cfbe73c3ae9db \
+ --hash=sha256:baad945ed47f163ad833314f8e3288c396118934f94e7bbb9e243ce4b341a4fd \
+ --hash=sha256:bae96332410f866e5900d809298b1ed82735932986c672495f9701daacd80620 \
+ --hash=sha256:bc837c1b9631be10abfe0191537fe8009838204cec7e44827401ace390ddb567 \
+ --hash=sha256:bf3ea07d9bc8e7d03a9fbc051295462e6dbc295b894fd72457c3136e3e43d898 \
+ --hash=sha256:c22a488f7b9218e245a0025a9ba6b100e2e54700831cf4cf16833a27fba3ad01 \
+ --hash=sha256:c4b4717bdb35ae456f831a6b08d01880fff399887a6bbc526a583a406e484eea \
+ --hash=sha256:c5fc4f8def331036a7b8e981b4347ebe409981edbc8308a5ea842b8c3614fa6c \
+ --hash=sha256:c682bea068a90b764577bdb78a60a4c1d1606daf9cd4c893832a37c7cc9d9026 \
+ --hash=sha256:c90c0f63df322be920eda6ce622e3083d8906ba267f8220fe7873213b8b4430e \
+ --hash=sha256:c9c53be232c2e206ef9cdbad81a48bfa74c3d3f08bcf8124630a8a748aad993e \
+ --hash=sha256:cbd18dd5e2df96b580487b5745adf57ef64ad89ba2d9662fc3c19386acce7db8 \
+ --hash=sha256:cf0d73f50e7b6935677854f6e8e31d499ca7064dd24734f703e060f5b237d883 \
+ --hash=sha256:d284fb8d94d5855d60c44fefcab4bf966f1da6fada73992b01f6f0c9bc0c6702 \
+ --hash=sha256:d28bb3c26762358dadf3e5bf0bccd29ae987d65e6988d2e6f49829c76b003c09 \
+ --hash=sha256:d5bf78d0e05e45cfdd66558893938d59afe3d1b1a824a202039b20e607d25a72 \
+ --hash=sha256:d5f91b1c27fc22a57993d5a5cb8a627cb8ed4b10502716fac1ffbfe1d19d84e8 \
+ --hash=sha256:d8f80521644426d451e70f00c7974240cab8f6ee088aedaa9af2697153ab7805 \
+ --hash=sha256:da3d7ec75dc83bb18bca888b5edfae0656a26849056c59e05a7728badd17e7af \
+ --hash=sha256:dab907db06fc593645e73109acf4581ba5b548897d28b9348dc41ddc8343b2d3 \
+ --hash=sha256:de5ba8763e56b793561f43bed197c9ea55776daa5e9a6b91eed68a909bc9cdbf \
+ --hash=sha256:e03a5f21a5ce96a9441b8cb32719a8b88ed5388f53e0f339c5bcf54f1317f9d0 \
+ --hash=sha256:e34e97bda77eb63242a410243c071e28ac7e0d8c0948c5ee658498690a4b2f2f \
+ --hash=sha256:e431cfc9caf44c1d5459ff77d4e64cbf85fddb6a35dad836a15c6a9ec23087c1 \
+ --hash=sha256:eab2cd170150e70153de16896a1774e3a1dca80154c56b54d7a812c479a7165e \
+ --hash=sha256:ee6162b77d49a9939229df666dfa8af3e656b6701b54c4c84966d740e189264e \
+ --hash=sha256:f17d61a28b4b3e0e3e2ba98490c70501403b4d196f78732439160e7fd3678127 \
+ --hash=sha256:f3bd327cdfa118bc1ce69c214c2678571d5bd39b8ccd0ebf43a54db00541ba9a \
+ --hash=sha256:f4444a83f946605990c98f625cdd3d2725bfb818158760c5748c653170a20e0e \
+ --hash=sha256:fb08c276dd02dac3a284acdd02cacc630d2e3cd6572a4b85519f35cbd133c3de
+ # via anthropic
+pycparser==3.0 \
+ --hash=sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29 \
+ --hash=sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992
+ # via cffi
+pydantic==2.13.4 \
+ --hash=sha256:45a282cde31d808236fd7ea9d919b128653c8b38b393d1c4ab335c62924d9aba \
+ --hash=sha256:c40756b57adaa8b1efeeced5c196f3f3b7c435f90e84ea7f443901bec8099ef6
+ # via
+ # -r backend/requirements.txt
+ # anthropic
+ # fastapi
+pydantic-core==2.46.4 \
+ --hash=sha256:00c603d540afdd6b80eb39f078f33ebd46211f02f33e34a32d9f053bba711de0 \
+ --hash=sha256:0186750b482eefa11d7f435892b09c5c606193ef3375bcf94aa00ae6bfb66262 \
+ --hash=sha256:041bde0a48fd37cf71cab1c9d56d3e8625a3793fef1f7dd232b3ff37e978ecda \
+ --hash=sha256:0c563b08bca408dc7f65f700633d8442fffb2421fc47b8101377e9fd65051ff0 \
+ --hash=sha256:0cbe8b01f948de4286c74cdd6c667aceb38f5c1e26f0693b3983d9d74887c65e \
+ --hash=sha256:0ce40cd7b21210e99342afafbd4d0f76d784eb5b1d60f3bdc566be4983c6c73b \
+ --hash=sha256:0e96592440881c74a213e5ad528e2b24d3d4f940de2766bed9010ab1d9e51594 \
+ --hash=sha256:10e17cbb10a330363733efc4d7c4d0dd827ac0909b8f6a6542298fed1ea62f29 \
+ --hash=sha256:133878133d271ade3d41d1bfb2a45ec38dbdbda40bc065921c6b04e4630127e2 \
+ --hash=sha256:14d4edf427bdcf950a8a02d7cb44a08614388dd6e1bdcbf4f67504fa7887da9c \
+ --hash=sha256:14f4c5d6db102bd796a627bbb3a17b4cf4574b9ae861d8b7c9a9661c6dd3362d \
+ --hash=sha256:17299feefe090f2caa5b8e37222bb5f663e4935a8bfa6931d4102e5df1a9f398 \
+ --hash=sha256:184c081504d17f1c1066e430e117142b2c77d9448a97f7b65c6ac9fd9aee238d \
+ --hash=sha256:18e5ceec2ab67e6d5f1a9085e5a24c9c4e2ac4545730bfe668680bca05e555f3 \
+ --hash=sha256:19e51f073cd3df251856a8a4189fbdf1de4012c3ebacfb1884f94f1eb406079f \
+ --hash=sha256:1a7dd0b3ee80d90150e3495a3a13ac34dbcbfd4f012996a6a1d8900e91b5c0fb \
+ --hash=sha256:1d8ba486450b14f3b1d63bc521d410ec7565e52f887b9fb671791886436a42f7 \
+ --hash=sha256:2108ba5c1c1eca18030634489dc544844144ee36357f2f9f780b93e7ddbb44b5 \
+ --hash=sha256:228ee9bae8bef5b1e97ec58302f80357c37199e0d0a99174e138d28e6957b9d9 \
+ --hash=sha256:23ace664830ee0bfe014a0c7bc248b1f7f25ed7ad103852c317624a1083af462 \
+ --hash=sha256:2412e734dcb48da14d4e4006b82b46b74f2518b8a26ee7e58c6844a6cd6d03c4 \
+ --hash=sha256:29c61fc04a3d840155ff08e475a04809278972fe6aef51e2720554e96367e34b \
+ --hash=sha256:2f84c03c8607173d16b5a854ec68a2f9079ae03237a54fb506d13af47e1d018d \
+ --hash=sha256:3009f12e4e90b7f88b4f9adb1b0c4a3d58fe7820f3238c190047209d148026df \
+ --hash=sha256:3245406455a5d98187ec35530fd772b1d799b26667980872c8d4614991e2c4a2 \
+ --hash=sha256:3447661d99f75a3683a4cf5c87da72f2161964611864dbbeac7fbb118bb4bfc0 \
+ --hash=sha256:372429a130e469c9cd698925ce5fc50940b7a1336b0d82038e63d5bbc4edc519 \
+ --hash=sha256:395aebd9183f9d112f569aeb5b2214d1a10a33bec8456447f7fbdfa51d38d4cd \
+ --hash=sha256:3a233125ac121aa3ffba9a2b59edfc4a985a76092dc8279586ab4b71390875e7 \
+ --hash=sha256:3be77f45df024d789a672ae34f8b06fb346c4f9f46ea714956660ea4862e89ac \
+ --hash=sha256:3bf92c5d0e00fefaab325a4d27828fe6b6e2a21848686b5b60d2d9eeb09d76c6 \
+ --hash=sha256:3ecbc122d18468d06ca279dc26a8c2e2d5acb10943bb35e36ae92096dc3b5565 \
+ --hash=sha256:3fb702cd90b0446a3a1c5e470bfa0dd23c0233b676a9099ddcc964fa6ca13898 \
+ --hash=sha256:428e04521a40150c85216fc8b85e8d39fece235a9cf5e383761238c7fa9b96fb \
+ --hash=sha256:432c179df7874eeb73307aad2df0755e1ae0efa61ff0ea89b93e194411ae3928 \
+ --hash=sha256:4a05d69cba51d852c5c3e92758653245a50c0b646ced0cf05bd793ed592839d6 \
+ --hash=sha256:4c63ebc82684aa89d9a3bcbd13d515b3be44250dc68dd3bd81526c1cb31286c3 \
+ --hash=sha256:4fc73cb559bdb54b1134a706a2802a4cddd27a0633f5abb7e53056268751ac6a \
+ --hash=sha256:4fcbe087dbc2068af7eda3aa87634eba216dbda64d1ae73c8684b621d33f6596 \
+ --hash=sha256:56cb4851bcaf3d117eddcef4fe66afd750a50274b0da8e22be256d10e5611987 \
+ --hash=sha256:5855698a4856556d86e8e6cd8434bc3ac0314ee8e12089ae0e143f64c6256e4e \
+ --hash=sha256:5a4330cdbc57162e4b3aa303f588ba752257694c9c9be3e7ebb11b4aca659b5d \
+ --hash=sha256:5b712b53160b79a5850310b912a5ef8e57e56947c8ad690c227f5c9d7e561712 \
+ --hash=sha256:5d5902252db0d3cedf8d4a1bc68f70eeb430f7e4c7104c8c476753519b423008 \
+ --hash=sha256:617d7e2ca7dcb8c5cf6bcb8c59b8832c94b36196bbf1cbd1bfb56ed341905edd \
+ --hash=sha256:62f875393d7f270851f20523dd2e29f082bcc82292d66db2b64ea71f64b6e1c1 \
+ --hash=sha256:633147d34cf4550417f12e2b1a0383973bdf5cdfde212cb09e9a581cf10820be \
+ --hash=sha256:66ce7632c22d837c95301830e111ad0128a32b8207533b60896a96c4915192ea \
+ --hash=sha256:6b3ace8194b0e5204818c92802dcdca7fc6d88aabbb799d7c795540d9cd6d292 \
+ --hash=sha256:6f2eeda33a839975441c86a4119e1383c50b47faf0cbb5176985565c6bb02c33 \
+ --hash=sha256:7027560ee92211647d0d34e3f7cd6f50da56399d26a9c8ad0da286d3869a53f3 \
+ --hash=sha256:7283d57845ecf5a163403eb0702dfc220cc4fbdd18919cb5ccea4f95ee1cdab4 \
+ --hash=sha256:7a5f930472650a82629163023e630d160863fce524c616f4e5186e5de9d9a49b \
+ --hash=sha256:7bfb192b3f4b9e8a89b6277b6ce787564f62cfd272055f6e685726b111dc7826 \
+ --hash=sha256:811ff8e9c313ab425368bcbb36e5c4ebd7108c2bbf4e4089cfbb0b01eff63fac \
+ --hash=sha256:8233f2947cf85404441fd7e0085f53b10c93e0ee78611099b5c7237e36aacbf7 \
+ --hash=sha256:82cf5301172168103724d49a1444d3378cb20cdee30b116a1bd6031236298a5d \
+ --hash=sha256:8358a950c8909158e3df31538a7e4edc2d7265a7c54b47f0864d9e5bae9dcebf \
+ --hash=sha256:85bb3611ff1802f3ee7fdd7dbff26b56f343fb432d57a4728fdd49b6ef35e2f4 \
+ --hash=sha256:86e1a4418c6cd97d60c95c71164158eaf7324fae7b0923264016baa993eba6fc \
+ --hash=sha256:8b9bab013d1c7a79d3501ff86d0bc9c31bf587db4551677b96bec07df78c6b15 \
+ --hash=sha256:8c5dac79fa1614d1e06ca695109c6105923bd9c7d1d6c918d4e637b7e6b32fd3 \
+ --hash=sha256:8d0820e8192167f80d88d64038e609c31452eeca865b4e1d9950a27a4609b00b \
+ --hash=sha256:8daafc69c93ee8a0204506a3b6b30f586ef54028f52aeeeb5c4cfc5184fd5914 \
+ --hash=sha256:9037063db01f09b09e237c282b6792bd4da634b5402c4e7f0c61effed7701a04 \
+ --hash=sha256:905a0ed8ea6f2d61c1738835f99b699348d7857379083e5fc497fa0c967a407c \
+ --hash=sha256:90884113d8b48f760e9587002789ddd741e76ab9f89518cd1e43b1f1a52ec44b \
+ --hash=sha256:91a06d2e259ecfbd8c901d70c3c507900458498142b3026a296b7de4d1322cc9 \
+ --hash=sha256:926c9541b14b12b1681dca8a0b75feb510b06c6341b70a8e500c2fdcff837cce \
+ --hash=sha256:9401557acd873c3a7f3eb9383edef8ac4968f9510e340f4808d427e75667e7b4 \
+ --hash=sha256:9551187363ffc0de2a00b2e47c25aeaeb1020b69b668762966df15fc5659dd5a \
+ --hash=sha256:962ccbab7b642487b1d8b7df90ef677e03134cf1fd8880bf698649b22a69371f \
+ --hash=sha256:97e7cf2be5c77b7d1a9713a05605d49460d02c6078d38d8bef3cbe323c548424 \
+ --hash=sha256:9aa768456404a8bf48a4406685ac2bec8e72b62c69313734fa3b73cf33b3a894 \
+ --hash=sha256:9bc519fbf2b7578398853d815009ae5e4d4603d12f4e3f91da8c06852d3da3e9 \
+ --hash=sha256:9d56801be94b86a9da183e5f3766e6310752b99ff647e38b09a9500d88e46e76 \
+ --hash=sha256:9f444c499b3eefd3a92e348059471ea0c3a6e303d9c1cec09fa748fd9f895201 \
+ --hash=sha256:9fa8ae11da9e2b3126c6426f147e0fba88d96d65921799bb30c6abd1cb2c97fb \
+ --hash=sha256:a0f62d0a58f4e7da165457e995725421e0064f2255d8eccebc49f41bbc23b109 \
+ --hash=sha256:a396dcc17e5a0b164dbe026896245a4fa9ff402edca1dff0be3d53a517f74de4 \
+ --hash=sha256:aaa2a54443eff1950ba5ddc6b6ccda0d9c84a364276a62f969bdf2a390650848 \
+ --hash=sha256:ad785e92e6dc634c21555edc8bd6b64957ab844541bcb96a1366c202951ae526 \
+ --hash=sha256:af8244b2bef6aaad6d92cda81372de7f8c8d36c9f0c3ea36e827c60e7d9467a0 \
+ --hash=sha256:b078afbc25f3a1436c7a1d2cd3e322497ee99615ba97c563566fdf46aff1ee01 \
+ --hash=sha256:b2f69dec1725e79a012d920df1707de5caf7ed5e08f3be4435e25803efc47458 \
+ --hash=sha256:b8458003118a712e66286df6a707db01c52c0f52f7db8e4a38f0da1d3b94fc4e \
+ --hash=sha256:bb63e0198ca18aad131c089b9204c23079c3afa95487e561f4c522d519e55aba \
+ --hash=sha256:bfec22eab3c8cc2ceec0248aec886624116dc079afa027ecc8ad4a7e62010f8a \
+ --hash=sha256:c1747f85cee84c26985853c6f3d9bd3e75da5212912443fa111c113b9c246f39 \
+ --hash=sha256:c1b3f518abeca3aa13c712fd202306e145abf59a18b094a6bafb2d2bbf59192c \
+ --hash=sha256:c50f2528cf200c5eed56faf3f4e22fcd5f38c157a8b78576e6ba3168ec35f000 \
+ --hash=sha256:c68fcd102d71ea85c5b2dfac3f4f8476eff42a9e078fd5faefff6d145063536b \
+ --hash=sha256:c7a7bd4e39e8e4c12c39cd480356842b6a8a06e41b23a55a5e3e191718838ddf \
+ --hash=sha256:c94f0688e7b8d0a67abf40e57a7eaaecd17cc9586706a31b76c031f63df052b4 \
+ --hash=sha256:cbaf13819775b7f769bf4a1f066cb6df7a28d4480081a589828ef190226881cd \
+ --hash=sha256:cd2213145bcc2ba85884d0ac63d222fece9209678f77b9b4d76f054c561adb28 \
+ --hash=sha256:ce5c1d2a8b27468f433ca974829c44060b8097eedc39933e3c206a90ee49c4a9 \
+ --hash=sha256:d396ec2b979760aaf3218e76c24e65bd0aca24983298653b3a9d7a45f9e47b30 \
+ --hash=sha256:d51026d73fcfd93610abc7b27789c26b313920fcfb20e27462d74a7f8b06e983 \
+ --hash=sha256:d80ee3d731373b24cebbc10d689ca4ee1875caf0d5703a245db18efd4dd37fc1 \
+ --hash=sha256:d995260fdf4e1db774581b4900e0f832abe3c7c84996726bbc161b19c8f29e76 \
+ --hash=sha256:da4b951fe36dc7c3a1ccb4e3cd1747c3542b8c9ceede8fc86cae054e764485f5 \
+ --hash=sha256:daa27d92c36f24388fe3ad306b174781c747627f134452e4f128ea00ce1fe8c4 \
+ --hash=sha256:db06ffe51636ffe9ca531fe9023dd64bdd794be8754cb5df57c5498ae5b518a7 \
+ --hash=sha256:e0d65b8c354be7fb5f720c3caa8bc940bc2d20ce749c8e06135f07f8ed95dd7c \
+ --hash=sha256:e68b7a074f65a2fd746c52a7ce6142ab7006074ac269ace0c25cd8ba171f8066 \
+ --hash=sha256:e739fee756ba1010f8bcccb534252e85a35fe45ae92c295a06059ce58b74ccd3 \
+ --hash=sha256:e846ae7835bf0703ae43f534ab79a867146dadd59dc9ca5c8b53d5c8f7c9ef02 \
+ --hash=sha256:e9c26f834c65f5752f3f06cb08cb86a913ceb7274d0db6e267808a708b46bc89 \
+ --hash=sha256:ea793e075b70290d89d8142074262885d3f7da19634845135751bd6344f73b50 \
+ --hash=sha256:f027324c56cd5406ca49c124b0db10e56c69064fec039acc571c29020cc87c76 \
+ --hash=sha256:f13a646d65d09fbf1bc6b3a9635d30095c8e7e5cc419ff35ecc563c5fd04cd49 \
+ --hash=sha256:f47286a97f0bc9b8859519809077b91b2cefe4ae47fcbf5e466a009c1c5d742b \
+ --hash=sha256:f747929cf940cddb5b3668a390056ddd5ba2e5010615ea2dcf4f9c4f3ab8791d \
+ --hash=sha256:f99626688942fb746e545232e7726926f3be91b5975f8b55327665fafda991c7 \
+ --hash=sha256:f9fa868638bf362d3d138ea55829cefb3d5f4b0d7f142234382a15e2485dbec4 \
+ --hash=sha256:fbdb89b3e1c94a30cc5edfce477c6e6a5dc4d8f84665b455c27582f211a1c72c \
+ --hash=sha256:fc010ab034c8c7452522748bf937df58020d256ccae0874463d1f4d01758af8e \
+ --hash=sha256:fc3e9034a63de20e15e8ade85358bc6efc614008cab72898b4b4952bea0509ff \
+ --hash=sha256:fd8b3d9fd264be37976686c7f65cd52a83f5e84f4bfd2adf9c1d469676bbb6ae
+ # via pydantic
+python-dotenv==1.2.2 \
+ --hash=sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a \
+ --hash=sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3
+ # via -r backend/requirements.txt
+sniffio==1.3.1 \
+ --hash=sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2 \
+ --hash=sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc
+ # via anthropic
+sqlite-vec==0.1.9 \
+ --hash=sha256:1515727990b49e79bcaf75fdee2ffc7d461f8b66905013231251f1c8938e7786 \
+ --hash=sha256:1b62a7f0a060d9475575d4e599bbf94a13d85af896bc1ce86ee80d1b5b48e5fb \
+ --hash=sha256:1d52e30513bae4cc9778ddbf6145610434081be4c3afe57cd877893bad9f6b6c \
+ --hash=sha256:4a28dc12fa4b53d7b1dced22da2488fade444e96b5d16fd2d698cd670675cf32 \
+ --hash=sha256:4e921e592f24a5f9a18f590b6ddd530eb637e2d474e3b1972f9bbeb773aa3cb9
+ # via -r backend/requirements.txt
+starlette==1.3.1 \
+ --hash=sha256:05d0213193f2fbaae60e2ecb593b4add4262ad4e46536b54abe36f11a71724e0 \
+ --hash=sha256:c7372aae11c3c3f26a42df7bd626cec2f47d03483d261d369516a615a53714c6
+ # via fastapi
+typing-extensions==4.16.0 \
+ --hash=sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8 \
+ --hash=sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5
+ # via
+ # anthropic
+ # anyio
+ # fastapi
+ # pydantic
+ # pydantic-core
+ # starlette
+ # typing-inspection
+typing-inspection==0.4.2 \
+ --hash=sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7 \
+ --hash=sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464
+ # via
+ # fastapi
+ # pydantic
+uvicorn==0.52.0 \
+ --hash=sha256:3d887809810b89ed33501bcf0a9aba469b06ecd608158efce04bd6b48d8c9b08 \
+ --hash=sha256:ca8876ad6c1983f394157c168b39d52f6dd56dabf5602fa0982751cffc2293ae
+ # via -r backend/requirements.txt
diff --git a/backend/runtime-requirements.lock b/backend/runtime-requirements.lock
new file mode 100644
index 00000000..ec37e6cd
--- /dev/null
+++ b/backend/runtime-requirements.lock
@@ -0,0 +1,387 @@
+#
+# This file is autogenerated by pip-compile with Python 3.12
+# by the following command:
+#
+# make lock-python
+#
+anyio==4.14.2 \
+ --hash=sha256:9f505dda5ac9f0c8309b5e8bd445a8c2bf7246f3ce950121e45ea15bc41d1494 \
+ --hash=sha256:cfa139f3ed1a23ee8f88a145ddb5ac7605b8bbfd8592baacd7ce3d8bb4313c7f
+ # via httpx
+certifi==2026.7.22 \
+ --hash=sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775 \
+ --hash=sha256:741e2c3b351ddf169a738da9f2c048608ff7f2c5cc02f1ebc6b118bb090d5d55
+ # via
+ # httpcore
+ # httpx
+click==8.4.2 \
+ --hash=sha256:9a6cea6e60b17ebe0a44c5cc636d94f09bd66142c1cd7d8b4cd731c4917a15f6 \
+ --hash=sha256:e6f9f66136c816745b9d65817da91d61d957fb16e02e4dcd0552553c5a197b76
+ # via huggingface-hub
+filelock==3.32.2 \
+ --hash=sha256:87dd94cf281e586d135fa51132b8e3d9a598b316e90377a288663c9321036c82 \
+ --hash=sha256:c33351e1f49cae33414acbc6d56784e6ecee82514ec90795da1161fc4836b5b8
+ # via huggingface-hub
+fsspec==2026.7.0 \
+ --hash=sha256:b57ddbafedfaef7018c1ecab32aa200a9d7ca26b77965f64e48b70061249d279 \
+ --hash=sha256:c803c40f4cf860b49dea58ee3e1c33cb9c790520e233537e1340049f89b82a88
+ # via huggingface-hub
+h11==0.16.0 \
+ --hash=sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1 \
+ --hash=sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86
+ # via httpcore
+hf-xet==1.5.2 \
+ --hash=sha256:045f84440c55cdeb659cf1a1dd48c77bcd0d2e93632e2fea8f2c3bdee79f38ed \
+ --hash=sha256:1da28519496eb7c8094c11e4d25509b4a468457a0302d58136099db2fd9a671d \
+ --hash=sha256:4a5ecb9cda8512ba2aa8ee5d37c87a1422992165892d653098c7b90247481c3b \
+ --hash=sha256:580e59e29bf37aece1f2b68537de1e3fb04f43a23d910dcf6f128280b5bfbba4 \
+ --hash=sha256:6395cfe3c9cbead4f16b31808b0e67eac428b66c656f856e99636adaddea878f \
+ --hash=sha256:73044bd31bae33c984af832d19c752a0dffb67518fee9ddbd91d616e1101cf47 \
+ --hash=sha256:7db73c810500c54c6760be8c39d4b2e476974de85424c50063efc22fdda13025 \
+ --hash=sha256:8764488197c1d7b1378c8438c18d2eea902e150dbca0b0f0d2d32603fb9b5576 \
+ --hash=sha256:8d7446f72abbf7e01ca5ff131786bc2e74a56393462c17a6bf1e303fbab81db4 \
+ --hash=sha256:bee28c619622d36968056532fd49cf2b35ca75099b1d616c31a618a893491380 \
+ --hash=sha256:cde8cd167126bb6109b2ceb19b844433a4988643e8f3e01dd9dd0e4a34535097 \
+ --hash=sha256:d6f9c58549407b84b9a5383afd68db0acc42345326a3159990b36a5ca8a20e4e \
+ --hash=sha256:db78c39c83d6279daddc98e2238f373ab8980685556d42472b4ec51abcf03e8c \
+ --hash=sha256:e396ab0faf6298199ad7a95305c3ca8498cb825978a6485be6d00587ee4ec577 \
+ --hash=sha256:ecf63d1cb69a9a7319910f8f83fcf9b46e7a32dfcf4b8f8eeddb55f647306e65 \
+ --hash=sha256:f922b8f5fb84f1dd3d7ab7a1316354a1bca9b1c73ecfc19c76e51a2a49d29799 \
+ --hash=sha256:fd3add255549e8ef58fa35b2e42dc016961c050600444e7d77d030ba6b57120e
+ # via huggingface-hub
+httpcore==1.0.9 \
+ --hash=sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55 \
+ --hash=sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8
+ # via httpx
+httpx==0.28.1 \
+ --hash=sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc \
+ --hash=sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad
+ # via huggingface-hub
+huggingface-hub==1.26.0 \
+ --hash=sha256:c8cd4e2df1ba9402f77fce9b509ec1d52debb502551789473f34016acc14e361 \
+ --hash=sha256:e8cca670caa5d8dfa7e45bf45e86b466698198cd8150c021bcdb4a86b9252364
+ # via tokenizers
+idna==3.18 \
+ --hash=sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2 \
+ --hash=sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848
+ # via
+ # anyio
+ # httpx
+jinja2==3.1.6 \
+ --hash=sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d \
+ --hash=sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67
+ # via model2vec
+joblib==1.5.3 \
+ --hash=sha256:5fc3c5039fc5ca8c0276333a188bbd59d6b7ab37fe6632daa76bc7f9ec18e713 \
+ --hash=sha256:8561a3269e6801106863fd0d6d84bb737be9e7631e33aaed3fb9ce5953688da3
+ # via model2vec
+markupsafe==3.0.3 \
+ --hash=sha256:0303439a41979d9e74d18ff5e2dd8c43ed6c6001fd40e5bf2e43f7bd9bbc523f \
+ --hash=sha256:068f375c472b3e7acbe2d5318dea141359e6900156b5b2ba06a30b169086b91a \
+ --hash=sha256:0bf2a864d67e76e5c9a34dc26ec616a66b9888e25e7b9460e1c76d3293bd9dbf \
+ --hash=sha256:0db14f5dafddbb6d9208827849fad01f1a2609380add406671a26386cdf15a19 \
+ --hash=sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf \
+ --hash=sha256:0f4b68347f8c5eab4a13419215bdfd7f8c9b19f2b25520968adfad23eb0ce60c \
+ --hash=sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175 \
+ --hash=sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219 \
+ --hash=sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb \
+ --hash=sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6 \
+ --hash=sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab \
+ --hash=sha256:15d939a21d546304880945ca1ecb8a039db6b4dc49b2c5a400387cdae6a62e26 \
+ --hash=sha256:177b5253b2834fe3678cb4a5f0059808258584c559193998be2601324fdeafb1 \
+ --hash=sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce \
+ --hash=sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218 \
+ --hash=sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634 \
+ --hash=sha256:1ba88449deb3de88bd40044603fafffb7bc2b055d626a330323a9ed736661695 \
+ --hash=sha256:1cc7ea17a6824959616c525620e387f6dd30fec8cb44f649e31712db02123dad \
+ --hash=sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73 \
+ --hash=sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c \
+ --hash=sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe \
+ --hash=sha256:2a15a08b17dd94c53a1da0438822d70ebcd13f8c3a95abe3a9ef9f11a94830aa \
+ --hash=sha256:2f981d352f04553a7171b8e44369f2af4055f888dfb147d55e42d29e29e74559 \
+ --hash=sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa \
+ --hash=sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37 \
+ --hash=sha256:3537e01efc9d4dccdf77221fb1cb3b8e1a38d5428920e0657ce299b20324d758 \
+ --hash=sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f \
+ --hash=sha256:38664109c14ffc9e7437e86b4dceb442b0096dfe3541d7864d9cbe1da4cf36c8 \
+ --hash=sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d \
+ --hash=sha256:3b562dd9e9ea93f13d53989d23a7e775fdfd1066c33494ff43f5418bc8c58a5c \
+ --hash=sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97 \
+ --hash=sha256:4bd4cd07944443f5a265608cc6aab442e4f74dff8088b0dfc8238647b8f6ae9a \
+ --hash=sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19 \
+ --hash=sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9 \
+ --hash=sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9 \
+ --hash=sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc \
+ --hash=sha256:591ae9f2a647529ca990bc681daebdd52c8791ff06c2bfa05b65163e28102ef2 \
+ --hash=sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4 \
+ --hash=sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354 \
+ --hash=sha256:6b5420a1d9450023228968e7e6a9ce57f65d148ab56d2313fcd589eee96a7a50 \
+ --hash=sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698 \
+ --hash=sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9 \
+ --hash=sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b \
+ --hash=sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc \
+ --hash=sha256:7be7b61bb172e1ed687f1754f8e7484f1c8019780f6f6b0786e76bb01c2ae115 \
+ --hash=sha256:7c3fb7d25180895632e5d3148dbdc29ea38ccb7fd210aa27acbd1201a1902c6e \
+ --hash=sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485 \
+ --hash=sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f \
+ --hash=sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12 \
+ --hash=sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025 \
+ --hash=sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009 \
+ --hash=sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d \
+ --hash=sha256:949b8d66bc381ee8b007cd945914c721d9aba8e27f71959d750a46f7c282b20b \
+ --hash=sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a \
+ --hash=sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5 \
+ --hash=sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f \
+ --hash=sha256:a320721ab5a1aba0a233739394eb907f8c8da5c98c9181d1161e77a0c8e36f2d \
+ --hash=sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1 \
+ --hash=sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287 \
+ --hash=sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6 \
+ --hash=sha256:bc51efed119bc9cfdf792cdeaa4d67e8f6fcccab66ed4bfdd6bde3e59bfcbb2f \
+ --hash=sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581 \
+ --hash=sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed \
+ --hash=sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b \
+ --hash=sha256:c0c0b3ade1c0b13b936d7970b1d37a57acde9199dc2aecc4c336773e1d86049c \
+ --hash=sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026 \
+ --hash=sha256:c4ffb7ebf07cfe8931028e3e4c85f0357459a3f9f9490886198848f4fa002ec8 \
+ --hash=sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676 \
+ --hash=sha256:d2ee202e79d8ed691ceebae8e0486bd9a2cd4794cec4824e1c99b6f5009502f6 \
+ --hash=sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e \
+ --hash=sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d \
+ --hash=sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d \
+ --hash=sha256:de8a88e63464af587c950061a5e6a67d3632e36df62b986892331d4620a35c01 \
+ --hash=sha256:df2449253ef108a379b8b5d6b43f4b1a8e81a061d6537becd5582fba5f9196d7 \
+ --hash=sha256:e1c1493fb6e50ab01d20a22826e57520f1284df32f2d8601fdd90b6304601419 \
+ --hash=sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795 \
+ --hash=sha256:e2103a929dfa2fcaf9bb4e7c091983a49c9ac3b19c9061b6d5427dd7d14d81a1 \
+ --hash=sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5 \
+ --hash=sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d \
+ --hash=sha256:e8fc20152abba6b83724d7ff268c249fa196d8259ff481f3b1476383f8f24e42 \
+ --hash=sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe \
+ --hash=sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda \
+ --hash=sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e \
+ --hash=sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737 \
+ --hash=sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523 \
+ --hash=sha256:f42d0984e947b8adf7dd6dde396e720934d12c506ce84eea8476409563607591 \
+ --hash=sha256:f71a396b3bf33ecaa1626c255855702aca4d3d9fea5e051b41ac59a9c1c41edc \
+ --hash=sha256:f9e130248f4462aaa8e2552d547f36ddadbeaa573879158d721bbd33dfe4743a \
+ --hash=sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50
+ # via jinja2
+model2vec==0.8.2 \
+ --hash=sha256:4e88d1a5eb2136475ebd90505689046f61caf25c69371c331f21c6499637c110 \
+ --hash=sha256:f0ecfe994316e401dca583fbf6dd22079d308c05717dd36d40bff60f265431cf
+ # via -r backend/runtime-requirements.txt
+numpy==2.5.1 \
+ --hash=sha256:08d60c810432eb83360958dea0999ac4cfb94531ea8efcbf0b7f277c2068aeb2 \
+ --hash=sha256:09e9bfd8d2cf479c7d174804fb3811c53a8e9f20a37444008606b57d6b7a826d \
+ --hash=sha256:0bfebd8695f9863592fe744be833a258120b14a9f39da255e8aa8fade2c0ddd1 \
+ --hash=sha256:17a25e09640602e10bc8de0e6fa2b3fd68eedd84ba6d7842dc8f32f9ab87bd0b \
+ --hash=sha256:1c6759f538fb912fc46de0a6b1758ccf7b57bc7c7ebebc23974fdac3de8db0cd \
+ --hash=sha256:224ca51130ef7da85bea2191625181cb4f337f9cb64b471f10c1a12aa8b60077 \
+ --hash=sha256:24d0eb82c0541d3415a33425db64ae439dffccd7b4dbcb30e7c35120205c506a \
+ --hash=sha256:2ae0ca40bcb22d6ba59c1dfd5446f49940b0f2d821fde133f10dda11f816b84e \
+ --hash=sha256:2c889b56fe48b1018f764b0eec8df59ab654e9148aa91faa12596043500de277 \
+ --hash=sha256:30b44a6b53a7ae63c54c089a8726e5563ed302716c5b7ccc85afade40b0e7ff6 \
+ --hash=sha256:32985c896d897419ef8da6917872d80b78ad0ea26d85b23245c7366ffde76d75 \
+ --hash=sha256:3935f3b419b244a02732676fa5317a9193cc596a4c0646db07e5b421229ac9f7 \
+ --hash=sha256:4939237038ada79308dda3204ac6462df056b5672b2e25db1149cf873668b3e1 \
+ --hash=sha256:4b4ff1608417eb7a59da7b967bbb798cacfe071d2caf526a24281cd562072ed9 \
+ --hash=sha256:54ad769f17bc2d833b620851989f62054fb9ab93c969d9e1dc3c8e3d56beea21 \
+ --hash=sha256:59fda5e192b570217ec2580c96f00e9a7e12ef6866a900eb089b62c1a32545ca \
+ --hash=sha256:5a4c988b38d261deeeaad9954e3deb091ad905c94e8bb6708654ef1d97f286b0 \
+ --hash=sha256:5a6db61f9aaa57e369905c67d852045d3c4f7126405b29d09b19dec118e9c9cb \
+ --hash=sha256:6165343f81b56ef8f514f396989e529b61d9dc709b99421b07e9f3e698e2287d \
+ --hash=sha256:61ac47e772e6b8ea489e1d2f441a34c5c3ac17327e7ce294cbdf535795ad4e75 \
+ --hash=sha256:6c3fe51bc6a16453d452997053454f309e8e0ed7b42d6b361ce4ac8c32913d74 \
+ --hash=sha256:6eab239876581b2b3c5a242281b6007bbdbcd1c7085d7709bb57c5929b11e6bf \
+ --hash=sha256:78798bd5b9ad744056af8efa90e3b9ddaa53272a0848a483084a1cc0a13b2dc0 \
+ --hash=sha256:7c786fe9a5bbe360022e584c5a34cf6b54265c71bd7ec8ac3d8fec38968071f8 \
+ --hash=sha256:83ce9c80d5b521b0d77ddcbe5447c218d247929b6cc056ca5351342accfff0af \
+ --hash=sha256:9726558e8db4a5bf7929a70ae50f63abda4daf0efe810e3bfbab95976f75fc1a \
+ --hash=sha256:99d5095fa265a0c4152e7bb12759e14381ef5496152f1ce58f44bdf55c44beb4 \
+ --hash=sha256:a33276be12fa045805f477f22482088b66bb758ffbe89a9d21457de863a32e22 \
+ --hash=sha256:a48a113e6afea91f5608793bafa7ef2ad481fefbda87ec5069f483de61cb9fa3 \
+ --hash=sha256:ab451b59c5643c570974c43aef780703ef1d3b4965d2be07afd530615a9358d1 \
+ --hash=sha256:ab84dc6b074fa881cae55bea94cc4f68e285181ba7f32497bf7dee6b1496165b \
+ --hash=sha256:ab87a91b3cc3382b8956095bd8f95e00cf679bb81554339be1a2ba404a1473c1 \
+ --hash=sha256:c12afb53450fa976d4c681c50a7423729a4c51c0465ed9f32b8a9cabbc472373 \
+ --hash=sha256:caf3e317d33d60c37986b452613f4ab51246d0691350c03d0cb4a898627f4a95 \
+ --hash=sha256:dc932a65ded7ce9013d120845a2514dcccb1a67bfc8deb8d37633762951904a6 \
+ --hash=sha256:e68d8dd1e7eba712948f2053a29ec86917bc70ba1358df869d9f06649ef9cf09 \
+ --hash=sha256:e824c2acf8862052246be5a44c15da1777940c60d010dd2aab897824d9c430f9 \
+ --hash=sha256:e8c11c405efc5ff6816d5983c96cdfa215bab3428961243af3ff59b228490438 \
+ --hash=sha256:efd736408cc97c79b9e6917338dfc8f06013b2274f992e96b1d9a81a71e2a2c2 \
+ --hash=sha256:f089d7b00756190aacf1f5d34bdf38c3c430ac82b4f868f8cede73380460fce7 \
+ --hash=sha256:f2479a47f8d5932d1718168a681ad6e536a9df484c83cfcf9de365e164537ace \
+ --hash=sha256:f7119ebff1a9829e9f431a4f9d28e703023bb6b9fe7c8f724467dbfc27c94ab3 \
+ --hash=sha256:f7d60026c0bdb1380e83bfa7a0419c4577ee4b9a08880afcb6dadeb74c649fa2 \
+ --hash=sha256:f7feb014281029e628ba2d5a007407443b06e418b6fe451d1e2adcbc8eba0107
+ # via model2vec
+packaging==26.2 \
+ --hash=sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e \
+ --hash=sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661
+ # via huggingface-hub
+pysqlite3==0.6.0 \
+ --hash=sha256:193488f62ebafcca1b72beb3daba98e4bc3be62ca6e3adee5bff603eadb535f7 \
+ --hash=sha256:1cc753922baa703496b6edbf92687389490b0e7ad61b7ed4925e6cf2a7def0a1 \
+ --hash=sha256:268fa5873143cccd79f32624c9a25714e4595715795ae09c8366c75cae8f5aed \
+ --hash=sha256:2886b6ccf9c3c7ce678ea795a2aa9ddb4563c50c81bce399560fdb2c5662e55d \
+ --hash=sha256:30bc9471bca6cfb35865e1a7ec6383b7fd37271edb7748941329709ef680c478 \
+ --hash=sha256:4a2dd23831c3fef68db0761c685b9ca5286dc8d58b090bc5c055f389d1b319a5 \
+ --hash=sha256:4f03a69a9c64d0476bbf9c619e1024770368b08e6b572c3f5f0fa641f1b67f27 \
+ --hash=sha256:503e570d0c771ea2a79397523443687e5ca149e93530487dd61b9763b393be58 \
+ --hash=sha256:51f24094c55039bd10c0a2d83e3c502039bf4bfa2b199ecf326815c52ec30bfb \
+ --hash=sha256:5ae52d27b48a634f1015051d791d23a5bd3f0a992099a632bf39bca21a156420 \
+ --hash=sha256:60cc74e051af0bef460950ea975d2d6c80a2145203f23653673a19cc367a6bb7 \
+ --hash=sha256:64d48638e2c787f1e7ca0100294740f4db7f6bce1516ba8076e082d6443925d4 \
+ --hash=sha256:65a9af11004d350538b6bd852b0ec22cdd62928be9ab522d85c0840d5bf92a72 \
+ --hash=sha256:81226436fae52d3bec4ce578104c5bcf4c22d3fcf29d4a590fcbde56b90472ef \
+ --hash=sha256:86d1a3ab3351984ddfa1a32ce51b378450dc9c53147a0e663febe077436db79a \
+ --hash=sha256:87368097abc224c25719e186a9173f80b1253cf0d84989791804e9c12440fcd6 \
+ --hash=sha256:97550d7f7b4714a174920799841fa61ddeb340bc662e421e2ebc12d8474fc89c \
+ --hash=sha256:a7855d3016392fa81571404fc1647a1d07d1128ff862652e1f89e59edcc3405e \
+ --hash=sha256:adc5faf38dcc1c47818e993c6998ee044643b6863ae89db693b36173d26b2400 \
+ --hash=sha256:b4b1f27e8cfe15a4b13448a3ff48daa0a3cfd0bdb202aeb2378d768c14407b28 \
+ --hash=sha256:b6f768d6e00d4574f5cc1bff174a4703382afe221b04b009bcb8b41164af08a0 \
+ --hash=sha256:c02894e33691c742c62e4219a03b9683e1fdd4bad7d799c3395df8aefcd5ed17 \
+ --hash=sha256:c4f85a31e80520351e7bc8e452e048bbc4470e17ca1882105cfe90833b476361 \
+ --hash=sha256:c86cd86b2833b43fb4c01b8c90e71f70e95105995f4b1e27b8d9d5c906512992 \
+ --hash=sha256:cc9787dc409f800e310c8a457b7c98d1c4d4a76e15821b7fb460c0e63554ddd8 \
+ --hash=sha256:e3e0ff8dfd84926a06cac62905b7659be7152ced4f6c1ac8ea381a1281555cce \
+ --hash=sha256:e52ec831759304540b53b550a230c698c3c4b9349732e7a2365bda5faa14854f \
+ --hash=sha256:ec9ec8cb87132f912dead528c8d8e7b71348a5b83b808515114f8b95bd8f742a \
+ --hash=sha256:ecf5112b62a4e6c04438957e343fe9672707bd3191f789ecae6c95b226aa6bb6 \
+ --hash=sha256:ed0da0211335335900e699f93c7fe7292ab3437b015ee1310ee5199fd9d05c29 \
+ --hash=sha256:f88d0f8f45bf3644ddd3e7c7de4b6b8d5b10a1bfe23db55b5a1ed95cbd4a0cb9
+ # via -r backend/runtime-requirements.txt
+pyyaml==6.0.3 \
+ --hash=sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c \
+ --hash=sha256:0150219816b6a1fa26fb4699fb7daa9caf09eb1999f3b70fb6e786805e80375a \
+ --hash=sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3 \
+ --hash=sha256:02ea2dfa234451bbb8772601d7b8e426c2bfa197136796224e50e35a78777956 \
+ --hash=sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6 \
+ --hash=sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c \
+ --hash=sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65 \
+ --hash=sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a \
+ --hash=sha256:1ebe39cb5fc479422b83de611d14e2c0d3bb2a18bbcb01f229ab3cfbd8fee7a0 \
+ --hash=sha256:214ed4befebe12df36bcc8bc2b64b396ca31be9304b8f59e25c11cf94a4c033b \
+ --hash=sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1 \
+ --hash=sha256:22ba7cfcad58ef3ecddc7ed1db3409af68d023b7f940da23c6c2a1890976eda6 \
+ --hash=sha256:27c0abcb4a5dac13684a37f76e701e054692a9b2d3064b70f5e4eb54810553d7 \
+ --hash=sha256:28c8d926f98f432f88adc23edf2e6d4921ac26fb084b028c733d01868d19007e \
+ --hash=sha256:2e71d11abed7344e42a8849600193d15b6def118602c4c176f748e4583246007 \
+ --hash=sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310 \
+ --hash=sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4 \
+ --hash=sha256:3c5677e12444c15717b902a5798264fa7909e41153cdf9ef7ad571b704a63dd9 \
+ --hash=sha256:3ff07ec89bae51176c0549bc4c63aa6202991da2d9a6129d7aef7f1407d3f295 \
+ --hash=sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea \
+ --hash=sha256:418cf3f2111bc80e0933b2cd8cd04f286338bb88bdc7bc8e6dd775ebde60b5e0 \
+ --hash=sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e \
+ --hash=sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac \
+ --hash=sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9 \
+ --hash=sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7 \
+ --hash=sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35 \
+ --hash=sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb \
+ --hash=sha256:5cf4e27da7e3fbed4d6c3d8e797387aaad68102272f8f9752883bc32d61cb87b \
+ --hash=sha256:5e0b74767e5f8c593e8c9b5912019159ed0533c70051e9cce3e8b6aa699fcd69 \
+ --hash=sha256:5ed875a24292240029e4483f9d4a4b8a1ae08843b9c54f43fcc11e404532a8a5 \
+ --hash=sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b \
+ --hash=sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c \
+ --hash=sha256:6344df0d5755a2c9a276d4473ae6b90647e216ab4757f8426893b5dd2ac3f369 \
+ --hash=sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd \
+ --hash=sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824 \
+ --hash=sha256:66291b10affd76d76f54fad28e22e51719ef9ba22b29e1d7d03d6777a9174198 \
+ --hash=sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065 \
+ --hash=sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c \
+ --hash=sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c \
+ --hash=sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764 \
+ --hash=sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196 \
+ --hash=sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b \
+ --hash=sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00 \
+ --hash=sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac \
+ --hash=sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8 \
+ --hash=sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e \
+ --hash=sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28 \
+ --hash=sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3 \
+ --hash=sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5 \
+ --hash=sha256:9c57bb8c96f6d1808c030b1687b9b5fb476abaa47f0db9c0101f5e9f394e97f4 \
+ --hash=sha256:9c7708761fccb9397fe64bbc0395abcae8c4bf7b0eac081e12b809bf47700d0b \
+ --hash=sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf \
+ --hash=sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5 \
+ --hash=sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702 \
+ --hash=sha256:b30236e45cf30d2b8e7b3e85881719e98507abed1011bf463a8fa23e9c3e98a8 \
+ --hash=sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788 \
+ --hash=sha256:b865addae83924361678b652338317d1bd7e79b1f4596f96b96c77a5a34b34da \
+ --hash=sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d \
+ --hash=sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc \
+ --hash=sha256:bdb2c67c6c1390b63c6ff89f210c8fd09d9a1217a465701eac7316313c915e4c \
+ --hash=sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba \
+ --hash=sha256:c2514fceb77bc5e7a2f7adfaa1feb2fb311607c9cb518dbc378688ec73d8292f \
+ --hash=sha256:c3355370a2c156cffb25e876646f149d5d68f5e0a3ce86a5084dd0b64a994917 \
+ --hash=sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5 \
+ --hash=sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26 \
+ --hash=sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f \
+ --hash=sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b \
+ --hash=sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be \
+ --hash=sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c \
+ --hash=sha256:efd7b85f94a6f21e4932043973a7ba2613b059c4a000551892ac9f1d11f5baf3 \
+ --hash=sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6 \
+ --hash=sha256:fa160448684b4e94d80416c0fa4aac48967a969efe22931448d853ada8baf926 \
+ --hash=sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0
+ # via huggingface-hub
+safetensors==0.8.0 \
+ --hash=sha256:040070828e36dc8e122178bbbd5830ff9e97920affb84cbe0f46442497bed358 \
+ --hash=sha256:096ec1a98435df7beb08853bb5aa9081a84f23d0adc67ed1a0a10550f608373f \
+ --hash=sha256:2ddf52eac562eda224f99acfa7889d02968c1fd59a5b011ae7d8137c37e9c02d \
+ --hash=sha256:3ae091f16662658bdc019a4ff6cb4c085bb7d725eb5978b183ffd265863b6d2d \
+ --hash=sha256:4124502b78f03534117c848f87a39b8f31e577b15eff423bf8bfb95f2a8c30d0 \
+ --hash=sha256:4a95ae2b05d7726d751da4ebf626a2ca782b706e101bd894c95bc2450b1cffcc \
+ --hash=sha256:7a46e5ff292c356d6991e60942ba7f79817682d3a2cef0702136448cb9c4d235 \
+ --hash=sha256:7bc0a787ba8a35be368ee3574edfa2b1ad389eebd0a72e482ae275490e3f6c98 \
+ --hash=sha256:87eec7ffed2b809f05a398a8becb7d013f19f7837cd15d9748580d6cf30dbaf4 \
+ --hash=sha256:8e080062fcde23be189565e1c3305d16751a218ecf9412c8601e64204eb6f846 \
+ --hash=sha256:8e9f537aa183a38ace122d27303dcd986b26bd2a7591f9181d7f0c396f4677ca \
+ --hash=sha256:c554f85858e05226d3c2828e32395e677434685d6d94594a41643361c5e837f0 \
+ --hash=sha256:c80201d22cbf405b80647a60ada77bba06c8fba2da2743ba1e89cdcc39a81f25 \
+ --hash=sha256:f7838e5135a406ad3e02efdcb8cf2e5397d368b0154537c4fec682dbc544d452 \
+ --hash=sha256:fabaf3e0f18a6618d9b36560682562157f77c2b71fcffc7b432be2baed9d753d \
+ --hash=sha256:fcdd41ec4628fee5799f807c73c353629130fbd942aa23d83c623dd6c9d52d78 \
+ --hash=sha256:fd6f3f93c9a0a7cc2788ee63fb763353d4bd2e89b0751bc78fcf7dda00bea774
+ # via model2vec
+sqlite-vec==0.1.9 \
+ --hash=sha256:1515727990b49e79bcaf75fdee2ffc7d461f8b66905013231251f1c8938e7786 \
+ --hash=sha256:1b62a7f0a060d9475575d4e599bbf94a13d85af896bc1ce86ee80d1b5b48e5fb \
+ --hash=sha256:1d52e30513bae4cc9778ddbf6145610434081be4c3afe57cd877893bad9f6b6c \
+ --hash=sha256:4a28dc12fa4b53d7b1dced22da2488fade444e96b5d16fd2d698cd670675cf32 \
+ --hash=sha256:4e921e592f24a5f9a18f590b6ddd530eb637e2d474e3b1972f9bbeb773aa3cb9
+ # via -r backend/runtime-requirements.txt
+tokenizers==0.23.1 \
+ --hash=sha256:120468fb4c24faf0543c835a4fabafa4deb3f20a035c9b6e83d0b553a97615d4 \
+ --hash=sha256:1974288a609c343774f1b897c8b482c791ab17b75ab5c8c2b1737565c1d82288 \
+ --hash=sha256:1bf13402aff9bc533c89cb849ec3b412dc3fbeacc9744840e423d7bf3f7dc0e3 \
+ --hash=sha256:1feeeadf865a7915adc25445dea30e9933e593c31bb96c277cee36de227c8bfa \
+ --hash=sha256:5075b405006415ea148a992d093699c66eb01952bf59f4d5727089a98bda45a4 \
+ --hash=sha256:53b09e85775d5187941e7bab30e941b4134ab4a7dd8c68e783d231fb7ca27c51 \
+ --hash=sha256:56f3a77de629917652f876294dc9fe6bad4a0c43bc229dc72e59bb23a0f4729a \
+ --hash=sha256:93120a930b919416da7cd10a2f606ac9919cc69cacae7980fa2140e277660948 \
+ --hash=sha256:9d10a6d957ef01896dc274e890eee27d41bd0e74ef31e60616f0fc311345184e \
+ --hash=sha256:a26197957d8e4425dfba746315f3c425ea00cfa8367c5fbc4ec73447893dcea9 \
+ --hash=sha256:ae848657742035523fdf261773630cb819a26995fcd3d9ecae0c1daf6e5a4959 \
+ --hash=sha256:e03d6ffcbe0d56ee9c1ccd070e70a13fa750727c0277e138152acbc0252c2224 \
+ --hash=sha256:e0948bbb1ac1d7cdfc9fb6d62c596e3b7550036ad60ecd654a66ad273326324e \
+ --hash=sha256:e3d8f40ea6268047de7046906326abed5134f27d4e8447b23763afe5808c8a96 \
+ --hash=sha256:e7bfaf995c1bdbbd21d13539decb6650967013759318627d85daeb7881af16b7 \
+ --hash=sha256:ea5a0ce170074329faaa8ea3f6400ecde604b6678192688533af80980daae71a \
+ --hash=sha256:f836ca703b89ae07919a309f9651f7a88fd5a33d5f718ba5ad0870ec0256bad6
+ # via model2vec
+tqdm==4.70.0 \
+ --hash=sha256:55b0b0dbd97462d06ebee91e4dac24ed4d4702be82b24f07e6c1d27e08cea220 \
+ --hash=sha256:7f585706bfddbdebf89daac705b2dfcc16890130727d3197ca62c732b4310953
+ # via
+ # huggingface-hub
+ # model2vec
+typing-extensions==4.16.0 \
+ --hash=sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8 \
+ --hash=sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5
+ # via
+ # anyio
+ # huggingface-hub
diff --git a/backend/tests/test_auth_http.py b/backend/tests/test_auth_http.py
index 5e5ab56a..a1e543e0 100644
--- a/backend/tests/test_auth_http.py
+++ b/backend/tests/test_auth_http.py
@@ -10,7 +10,7 @@
- enumeration-resistant response shapes
- cxs_ works on data routes but is REJECTED by /v1/admin/* and /mcp
- activation provisions the user (registry row + usable shard, NO auto tokens)
-- OAuth callback journeys against a faked transport: auto-signup, login, and
+- OAuth callback journeys against a faked transport: consented signup, login, and
the never-silent-auto-link link_required challenge
- app start/poll handoff: poll_secret single-use, no token in any URL
- self-serve cxa_ mint (account_id linkage) that then authenticates
@@ -85,6 +85,7 @@ def setUp(self) -> None:
shard_mode=self.shard_mode,
default_user_id="hosted-default",
require_scoped_api_tokens=True,
+ legal_terms_approved=True,
auth_enabled=True,
accounts_db_path=None,
auth_email_mode="log",
@@ -126,7 +127,13 @@ def _last_flow_token(self, kind: str) -> str:
def _signup_and_verify(self, email: str = "user@example.com") -> dict[str, Any]:
response = self.client.post(
- "/v1/auth/signup", json={"email": email, "password": PASSWORD}
+ "/v1/auth/signup",
+ json={
+ "email": email,
+ "password": PASSWORD,
+ "terms_accepted": True,
+ "age_confirmed": True,
+ },
)
self.assertEqual(response.status_code, 200, response.text)
self.assertEqual(response.json(), {"ok": True, "next": "verify_email"})
@@ -144,6 +151,35 @@ def _login(self, email: str = "user@example.com", password: str = PASSWORD) -> d
self.assertEqual(response.status_code, 200, response.text)
return response.json()
+ def test_hosted_signup_requires_explicit_legal_and_age_consent(self) -> None:
+ response = self.client.post(
+ "/v1/auth/signup",
+ json={"email": "no-consent@example.com", "password": PASSWORD},
+ )
+ self.assertEqual(response.status_code, 422, response.text)
+ self.assertIsNone(
+ self.runtime.control_store.get_account_by_email("no-consent@example.com")
+ )
+
+ def test_hosted_signup_is_disabled_until_legal_text_is_approved(self) -> None:
+ original = main_module.settings
+ main_module.settings = replace(original, legal_terms_approved=False)
+ try:
+ page = self.client.get("/account/signup")
+ self.assertEqual(page.status_code, 503)
+ response = self.client.post(
+ "/v1/auth/signup",
+ json={
+ "email": "legal-block@example.com",
+ "password": PASSWORD,
+ "terms_accepted": True,
+ "age_confirmed": True,
+ },
+ )
+ self.assertEqual(response.status_code, 503, response.text)
+ finally:
+ main_module.settings = original
+
def _bearer(self, token: str) -> dict[str, str]:
return {"Authorization": f"Bearer {token}"}
@@ -190,8 +226,21 @@ def _google_claims(self, nonce: str, **overrides: Any) -> dict[str, Any]:
claims.update(overrides)
return claims
- def _oauth_start(self, app_flow: str | None = None) -> tuple[str, str]:
+ def _oauth_start(
+ self,
+ app_flow: str | None = None,
+ *,
+ signup: bool = False,
+ ) -> tuple[str, str]:
params = {"app_flow": app_flow} if app_flow else {}
+ if signup:
+ params.update(
+ {
+ "signup": "true",
+ "terms_accepted": "true",
+ "age_confirmed": "true",
+ }
+ )
started = self.client.get("/v1/auth/oauth/google/start", params=params)
self.assertEqual(started.status_code, 200, started.text)
state = started.json()["state"]
@@ -262,7 +311,13 @@ def test_autoverify_beta_mode_activates_at_signup(self) -> None:
main_module.settings = replace(main_module.settings, auth_autoverify=True)
try:
response = self.client.post(
- "/v1/auth/signup", json={"email": "beta@example.com", "password": PASSWORD}
+ "/v1/auth/signup",
+ json={
+ "email": "beta@example.com",
+ "password": PASSWORD,
+ "terms_accepted": True,
+ "age_confirmed": True,
+ },
)
self.assertEqual(response.status_code, 200, response.text)
# Identical generic shape to the non-autoverify path — no enumeration signal.
@@ -287,7 +342,13 @@ def test_autoverify_off_by_default_keeps_pending(self) -> None:
# Default (public) profile: signup stays pending_verification; the safety default.
self.assertFalse(main_module.settings.auth_autoverify)
response = self.client.post(
- "/v1/auth/signup", json={"email": "pending@example.com", "password": PASSWORD}
+ "/v1/auth/signup",
+ json={
+ "email": "pending@example.com",
+ "password": PASSWORD,
+ "terms_accepted": True,
+ "age_confirmed": True,
+ },
)
self.assertEqual(response.status_code, 200, response.text)
account = self.runtime.control_store.get_account_by_email("pending@example.com")
@@ -295,10 +356,22 @@ def test_autoverify_off_by_default_keeps_pending(self) -> None:
def test_enumeration_resistant_shapes(self) -> None:
first = self.client.post(
- "/v1/auth/signup", json={"email": "dupe@example.com", "password": PASSWORD}
+ "/v1/auth/signup",
+ json={
+ "email": "dupe@example.com",
+ "password": PASSWORD,
+ "terms_accepted": True,
+ "age_confirmed": True,
+ },
)
second = self.client.post(
- "/v1/auth/signup", json={"email": "dupe@example.com", "password": PASSWORD}
+ "/v1/auth/signup",
+ json={
+ "email": "dupe@example.com",
+ "password": PASSWORD,
+ "terms_accepted": True,
+ "age_confirmed": True,
+ },
)
self.assertEqual(first.status_code, second.status_code)
self.assertEqual(first.json(), second.json())
@@ -390,7 +463,26 @@ def test_github_oauth_start_uses_public_app_callback(self) -> None:
["http://127.0.0.1:8766/v1/auth/oauth/github/callback"],
)
- def test_auto_signup_then_login_via_google(self) -> None:
+ def test_login_oauth_cannot_silently_create_an_unknown_account(self) -> None:
+ fake = self._install_fake_google()
+ state, nonce = self._oauth_start()
+ fake["claims"] = self._google_claims(
+ nonce,
+ sub="google-no-consent",
+ email="no-oauth-consent@example.com",
+ )
+ callback = self.client.get(
+ "/v1/auth/oauth/google/callback",
+ params={"code": "auth-code", "state": state},
+ )
+ self.assertEqual(callback.status_code, 409, callback.text)
+ self.assertIsNone(
+ self.runtime.control_store.get_account_by_email(
+ "no-oauth-consent@example.com"
+ )
+ )
+
+ def test_consented_signup_then_login_via_google(self) -> None:
fake = self._install_fake_google()
providers = self.client.get("/v1/auth/providers")
self.assertEqual(providers.status_code, 200)
@@ -398,8 +490,8 @@ def test_auto_signup_then_login_via_google(self) -> None:
self.assertIn("google", names)
self.assertNotIn("openai", names)
- # Unknown identity with a provider-verified email: auto-signup, ACTIVE.
- state, nonce = self._oauth_start()
+ # Unknown identity with a provider-verified email and bound consent: signup, ACTIVE.
+ state, nonce = self._oauth_start(signup=True)
fake["claims"] = self._google_claims(nonce)
callback = self.client.get(
"/v1/auth/oauth/google/callback", params={"code": "auth-code", "state": state}
@@ -496,7 +588,7 @@ def test_app_start_poll_handoff(self) -> None:
# value directly to simulate the browser holding it.
from backend.app import main as _main
self.client.cookies.set("df_af", _main._app_flow_cookie(flow_id))
- state, nonce = self._oauth_start(app_flow=flow_id)
+ state, nonce = self._oauth_start(app_flow=flow_id, signup=True)
authorize_url = self.runtime.control_store.get_flow(state)
assert authorize_url is not None
fake["claims"] = self._google_claims(nonce, sub="google-sub-app")
@@ -729,6 +821,9 @@ def test_backfill_ensures_deks_and_migrates_plaintext(self) -> None:
vault.cipher = cipher
raw = json.loads(vault.credentials_path.read_text(encoding="utf-8"))
self.assertIn("payload", raw["users"][user_id]["legacy-acct"])
+ before = main_module._credential_encryption_evidence()
+ self.assertEqual(before["remaining_plaintext"], 1)
+ self.assertTrue(before["scan_complete"])
response = self.client.post("/v1/admin/encryption/backfill", headers=ADMIN)
self.assertEqual(response.status_code, 200, response.text)
@@ -741,9 +836,51 @@ def test_backfill_ensures_deks_and_migrates_plaintext(self) -> None:
record = raw_after["users"][user_id]["legacy-acct"]
self.assertIn("payload_cxe1", record)
self.assertNotIn("payload", record)
+ after = main_module._credential_encryption_evidence()
+ self.assertEqual(after["remaining_plaintext"], 0)
+ self.assertTrue(after["scan_complete"])
read_back = vault.read_source_credential(user_id=user_id, source_account_id="legacy-acct")
self.assertEqual(read_back["payload"]["token"], "legacy-plaintext")
+ def test_readiness_counts_plaintext_even_beside_an_envelope(self) -> None:
+ account = self._signup_and_verify(email="partial-migration@example.com")
+ user_id = account["user_id"]
+ vault = main_module.store.store_for_user(user_id).vault
+ cipher = vault.cipher
+ vault.cipher = None
+ try:
+ vault.write_source_credential(
+ user_id=user_id,
+ source_account_id="partial-acct",
+ source="notion",
+ payload={"token": "still-plaintext"},
+ )
+ finally:
+ vault.cipher = cipher
+ raw = json.loads(vault.credentials_path.read_text(encoding="utf-8"))
+ raw["users"][user_id]["partial-acct"]["payload_cxe1"] = "00"
+ vault.credentials_path.write_text(json.dumps(raw), encoding="utf-8")
+
+ evidence = main_module._credential_encryption_evidence()
+
+ self.assertEqual(evidence["remaining_plaintext"], 1)
+ self.assertGreaterEqual(evidence["invalid_records"], 1)
+
+ def test_readiness_evidence_fails_closed_when_scan_is_truncated(self) -> None:
+ account = self._signup_and_verify(email="scan-limit@example.com")
+ vault = main_module.store.store_for_user(account["user_id"]).vault
+ vault.write_source_credential(
+ user_id=account["user_id"],
+ source_account_id="encrypted-acct",
+ source="notion",
+ payload={"token": "encrypted"},
+ )
+
+ evidence = main_module._credential_encryption_evidence(max_files=0)
+
+ self.assertFalse(evidence["scan_complete"])
+ self.assertEqual(evidence["files_scanned"], 0)
+
class AuthDisabledTests(unittest.TestCase):
"""Default boot (no auth env): the auth surface must be absent and the
diff --git a/backend/tests/test_authn_sessions.py b/backend/tests/test_authn_sessions.py
index e84590d8..33c4e91c 100644
--- a/backend/tests/test_authn_sessions.py
+++ b/backend/tests/test_authn_sessions.py
@@ -11,11 +11,14 @@
import tempfile
import threading
+import time
import unittest
from datetime import datetime, timedelta, timezone
from pathlib import Path
+from unittest.mock import patch
from backend.app.accounts import SQLiteControlStore
+from backend.app import authn as authn_module
from backend.app.authn import (
ACCESS_TOKEN_PREFIX,
GENERIC_AUTH_FAILURE,
@@ -129,6 +132,48 @@ def test_dummy_verify_always_false(self) -> None:
self.assertFalse(engine.dummy_verify("anything"))
self.assertFalse(engine.dummy_verify("anything")) # cached dummy path
+ def test_password_work_is_bounded_under_concurrency(self) -> None:
+ state_lock = threading.Lock()
+ active = 0
+ max_active = 0
+
+ class SlowHasher:
+ def hash(self, _password: str) -> str:
+ nonlocal active, max_active
+ with state_lock:
+ active += 1
+ max_active = max(max_active, active)
+ try:
+ time.sleep(0.03)
+ return "$argon2id$test"
+ finally:
+ with state_lock:
+ active -= 1
+
+ engine = PasswordEngine(**CHEAP_SCRYPT)
+ engine._hasher = SlowHasher()
+ errors: list[BaseException] = []
+
+ def worker() -> None:
+ try:
+ engine.hash("bounded-password")
+ except BaseException as exc: # noqa: BLE001 - assert all worker failures
+ errors.append(exc)
+
+ with patch.object(
+ authn_module,
+ "_PASSWORD_WORK_SLOTS",
+ threading.BoundedSemaphore(2),
+ ):
+ threads = [threading.Thread(target=worker) for _ in range(8)]
+ for thread in threads:
+ thread.start()
+ for thread in threads:
+ thread.join(timeout=2)
+
+ self.assertEqual(errors, [])
+ self.assertEqual(max_active, 2)
+
# ---------------------------------------------------------------------------
# Token discipline
diff --git a/backend/tests/test_billing.py b/backend/tests/test_billing.py
index 56482d24..56fcac3b 100644
--- a/backend/tests/test_billing.py
+++ b/backend/tests/test_billing.py
@@ -235,6 +235,7 @@ def setUp(self) -> None:
shard_mode="user",
default_user_id="hosted-default",
require_scoped_api_tokens=True,
+ legal_terms_approved=True,
auth_enabled=True,
accounts_db_path=None,
auth_email_mode="log",
@@ -275,7 +276,15 @@ def _last_flow_token(self, kind: str) -> str:
raise AssertionError(f"no {kind} delivery found")
def _signup_login(self, email: str = "buyer@example.com") -> dict[str, Any]:
- r = self.client.post("/v1/auth/signup", json={"email": email, "password": PASSWORD})
+ r = self.client.post(
+ "/v1/auth/signup",
+ json={
+ "email": email,
+ "password": PASSWORD,
+ "terms_accepted": True,
+ "age_confirmed": True,
+ },
+ )
self.assertEqual(r.status_code, 200, r.text)
v = self.client.post("/v1/auth/verify-email", json={"token": self._last_flow_token("email_verify")})
self.assertEqual(v.status_code, 200, v.text)
@@ -370,6 +379,7 @@ def setUp(self) -> None:
shard_mode="user",
default_user_id="hosted-default",
require_scoped_api_tokens=True,
+ legal_terms_approved=True,
auth_enabled=True,
accounts_db_path=None,
auth_email_mode="log",
@@ -412,7 +422,15 @@ def test_webhook_404_when_unconfigured(self) -> None:
def test_account_plan_reports_billing_disabled(self) -> None:
email = "nb@example.com"
- r = self.client.post("/v1/auth/signup", json={"email": email, "password": PASSWORD})
+ r = self.client.post(
+ "/v1/auth/signup",
+ json={
+ "email": email,
+ "password": PASSWORD,
+ "terms_accepted": True,
+ "age_confirmed": True,
+ },
+ )
self.assertEqual(r.status_code, 200, r.text)
token = None
for item in reversed(self.runtime.outbox):
diff --git a/backend/tests/test_billing_stripe.py b/backend/tests/test_billing_stripe.py
index 93386484..3e932a5d 100644
--- a/backend/tests/test_billing_stripe.py
+++ b/backend/tests/test_billing_stripe.py
@@ -291,6 +291,7 @@ def setUp(self) -> None:
shard_mode="user",
default_user_id="hosted-default",
require_scoped_api_tokens=True,
+ legal_terms_approved=True,
auth_enabled=True,
accounts_db_path=None,
auth_email_mode="log",
@@ -329,7 +330,15 @@ def _last_flow_token(self, kind: str) -> str:
raise AssertionError(f"no {kind} delivery found")
def _signup_login(self, email: str = "buyer@example.com") -> dict[str, Any]:
- r = self.client.post("/v1/auth/signup", json={"email": email, "password": PASSWORD})
+ r = self.client.post(
+ "/v1/auth/signup",
+ json={
+ "email": email,
+ "password": PASSWORD,
+ "terms_accepted": True,
+ "age_confirmed": True,
+ },
+ )
self.assertEqual(r.status_code, 200, r.text)
v = self.client.post("/v1/auth/verify-email", json={"token": self._last_flow_token("email_verify")})
self.assertEqual(v.status_code, 200, v.text)
diff --git a/backend/tests/test_config.py b/backend/tests/test_config.py
index 5763dfa2..3070ea71 100644
--- a/backend/tests/test_config.py
+++ b/backend/tests/test_config.py
@@ -25,6 +25,24 @@ def test_missing_global_token_does_not_create_sample_admin_token(self) -> None:
self.assertEqual(settings.api_key, "")
+ def test_public_base_url_follows_an_overridden_development_port(self) -> None:
+ with patch.dict(os.environ, {"CORTEX_PORT": "57490"}, clear=True):
+ settings = load_settings()
+ self.assertEqual(settings.public_base_url, "http://127.0.0.1:57490")
+ self.assertEqual(settings.public_app_url, "http://127.0.0.1:57490")
+
+ def test_explicit_public_base_url_overrides_the_development_port(self) -> None:
+ with patch.dict(
+ os.environ,
+ {
+ "CORTEX_PORT": "57490",
+ "CORTEX_PUBLIC_BASE_URL": "https://api.example.invalid",
+ },
+ clear=True,
+ ):
+ settings = load_settings()
+ self.assertEqual(settings.public_base_url, "https://api.example.invalid")
+
if __name__ == "__main__":
unittest.main()
diff --git a/backend/tests/test_context_engine.py b/backend/tests/test_context_engine.py
index ba196ad0..67b9f8c8 100644
--- a/backend/tests/test_context_engine.py
+++ b/backend/tests/test_context_engine.py
@@ -150,6 +150,19 @@ def test_entity_layer_carries_graph_connections(self) -> None:
labels = {connection.get("label") for connection in entity.get("connections") or []}
self.assertIn("Atlas", labels)
+ def test_project_is_explicitly_labeled_as_a_ranking_hint(self) -> None:
+ self._seed_corpus()
+ pack = self.store.assemble_context(
+ self.user_id,
+ "summarize Atlas",
+ project="Project Atlas",
+ )
+ self.assertEqual(pack["filters"]["project"], "Project Atlas")
+ self.assertEqual(
+ pack["filter_semantics"]["project"],
+ "ranking_hint_not_isolation",
+ )
+
def test_markdown_render_contains_included_ids(self) -> None:
self._seed_corpus()
pack = self.store.assemble_context(self.user_id, "what did we decide about Atlas?")
diff --git a/backend/tests/test_delivery.py b/backend/tests/test_delivery.py
index 62d0be32..c18d0098 100644
--- a/backend/tests/test_delivery.py
+++ b/backend/tests/test_delivery.py
@@ -3,6 +3,7 @@
import tempfile
import unittest
from pathlib import Path
+from unittest.mock import patch
from backend.app.delivery import build_delivery_payload, deliver_webhook, is_safe_webhook_url
@@ -14,9 +15,11 @@ def test_rejects_internal_and_insecure(self):
"https://127.0.0.1/hook",
"http://10.0.0.5/hook",
"https://169.254.169.254/latest/meta-data",
+ "https://100.64.0.1/hook", # shared CGNAT space is not globally routable
"http://8.8.8.8/hook", # public but not https
"ftp://example.com/x",
"https://user:pass@8.8.8.8/x", # credentials in URL
+ "https://8.8.8.8:not-a-port/x",
"not a url",
):
ok, _ = is_safe_webhook_url(url)
@@ -41,6 +44,26 @@ def test_ssrf_blocked_never_calls_sender(self):
self.assertFalse(result["ok"])
self.assertEqual(calls, [])
+ def test_sender_connects_to_the_address_that_passed_ssrf_validation(self):
+ resolution = [
+ (2, 1, 6, "", ("8.8.8.8", 443)),
+ ]
+ pinned_calls = []
+
+ def fake_post(parsed, address, body, headers, *, timeout):
+ pinned_calls.append((parsed.hostname, address, timeout))
+ return 204
+
+ with (
+ patch("backend.app.delivery.socket.getaddrinfo", return_value=resolution) as resolver,
+ patch("backend.app.delivery._post_to_pinned_target", side_effect=fake_post),
+ ):
+ result = deliver_webhook("https://hooks.example.test/path", {"a": 1})
+
+ self.assertTrue(result["ok"])
+ self.assertEqual(resolver.call_count, 1)
+ self.assertEqual(pinned_calls, [("hooks.example.test", "8.8.8.8", 15)])
+
class DeliveryEvalGateTests(unittest.TestCase):
def test_full_gate_passes(self):
diff --git a/backend/tests/test_email_sender.py b/backend/tests/test_email_sender.py
index d91d94c3..dec5df5f 100644
--- a/backend/tests/test_email_sender.py
+++ b/backend/tests/test_email_sender.py
@@ -271,7 +271,8 @@ def tearDown(self) -> None:
def test_signup_calls_sender_once_with_verification_recipient(self) -> None:
resp = self.client.post(
- "/v1/auth/signup", json={"email": "new@example.com", "password": PASSWORD}
+ "/v1/auth/signup",
+ json={"email": "new@example.com", "password": PASSWORD, "terms_accepted": True, "age_confirmed": True},
)
self.assertEqual(resp.status_code, 200, resp.text)
self.assertEqual(len(self.recording.calls), 1)
@@ -285,7 +286,8 @@ def test_signup_calls_sender_once_with_verification_recipient(self) -> None:
def test_send_failure_does_not_break_signup_and_keeps_outbox(self) -> None:
self.recording.fail = True
resp = self.client.post(
- "/v1/auth/signup", json={"email": "flaky@example.com", "password": PASSWORD}
+ "/v1/auth/signup",
+ json={"email": "flaky@example.com", "password": PASSWORD, "terms_accepted": True, "age_confirmed": True},
)
self.assertEqual(resp.status_code, 200, resp.text)
self.assertEqual(len(self.recording.calls), 1)
@@ -311,7 +313,8 @@ def test_misconfigured_smtp_degrades_to_log(self) -> None:
# The sender must be a LogEmailSender, not an SmtpEmailSender.
self.assertIsInstance(self.runtime.email_sender, LogEmailSender)
resp = self.client.post(
- "/v1/auth/signup", json={"email": "beta@example.com", "password": PASSWORD}
+ "/v1/auth/signup",
+ json={"email": "beta@example.com", "password": PASSWORD, "terms_accepted": True, "age_confirmed": True},
)
self.assertEqual(resp.status_code, 200, resp.text)
# outbox has the canonical token record — nothing lost.
diff --git a/backend/tests/test_embeddings.py b/backend/tests/test_embeddings.py
index 72ec45fc..4ec8ae01 100644
--- a/backend/tests/test_embeddings.py
+++ b/backend/tests/test_embeddings.py
@@ -54,7 +54,7 @@ def fake_urlopen(request, timeout):
"OPENAI_API_KEY": "sk-test",
},
):
- with patch("backend.app.embeddings.urllib.request.urlopen", side_effect=fake_urlopen):
+ with patch("backend.app.embeddings.open_same_origin", side_effect=fake_urlopen):
result = embed_text_result("Remember the search backend decision.")
self.assertEqual(result.provider, "openai")
@@ -82,7 +82,7 @@ def test_openai_provider_falls_back_to_hash_when_not_strict(self) -> None:
"OPENAI_API_KEY": "sk-test",
},
):
- with patch("backend.app.embeddings.urllib.request.urlopen", side_effect=TimeoutError("slow")):
+ with patch("backend.app.embeddings.open_same_origin", side_effect=TimeoutError("slow")):
result = embed_text_result("Fallback should keep local search usable.")
self.assertEqual(result.provider, "hash")
@@ -100,7 +100,7 @@ def test_openai_provider_raises_when_strict(self) -> None:
"OPENAI_API_KEY": "sk-test",
},
):
- with patch("backend.app.embeddings.urllib.request.urlopen", side_effect=TimeoutError("slow")):
+ with patch("backend.app.embeddings.open_same_origin", side_effect=TimeoutError("slow")):
with self.assertRaises(TimeoutError):
embed_text_result("Strict mode should expose provider failures.")
diff --git a/backend/tests/test_fastapi_contract.py b/backend/tests/test_fastapi_contract.py
index 0ec72849..5a5d90ee 100644
--- a/backend/tests/test_fastapi_contract.py
+++ b/backend/tests/test_fastapi_contract.py
@@ -17,6 +17,7 @@
from fastapi.testclient import TestClient
from backend.app import main as main_module
+from backend.app.keyring import LocalKekProvider, UserKeyring
from backend.app.provenance import sign_shared_write
from backend.tests.test_decision_history import CURRENT_DECISION_ID, CURRENT_SOURCE_URL, seed_decision_history_fixture
@@ -82,6 +83,62 @@ def _assert_connector_ask_citation(
self.assertIn("line=", citation["source_url"])
self.assertIn("excerpt=", citation["source_url"])
+ def test_openapi_schema_generates_with_unique_operation_ids(self) -> None:
+ schema = app.openapi()
+ self.assertEqual(schema["openapi"], "3.1.0")
+ self.assertIn("/v1/context", schema["paths"])
+ self.assertIn("/oauth/broker/exchange", schema["paths"])
+
+ operation_ids = [
+ operation["operationId"]
+ for path_item in schema["paths"].values()
+ for operation in path_item.values()
+ if isinstance(operation, dict) and "operationId" in operation
+ ]
+ self.assertTrue(operation_ids)
+ self.assertEqual(len(operation_ids), len(set(operation_ids)))
+ self.assertEqual(
+ schema["paths"]["/v1/context"]["post"]["operationId"],
+ "post_v1_context",
+ )
+ bearer = schema["components"]["securitySchemes"]["BearerAuth"]
+ self.assertEqual(bearer["type"], "http")
+ self.assertEqual(bearer["scheme"], "bearer")
+ self.assertIn(
+ {"BearerAuth": []},
+ schema["paths"]["/v1/context"]["post"]["security"],
+ )
+ request_schema = schema["paths"]["/v1/context"]["post"]["requestBody"][
+ "content"
+ ]["application/json"]["schema"]
+ self.assertEqual(
+ request_schema["$ref"],
+ "#/components/schemas/ContextRequest",
+ )
+
+ def test_context_post_rejects_unbounded_or_invalid_request_data(self) -> None:
+ headers = {"Authorization": "Bearer test-token"}
+ too_large = self.client.post(
+ "/v1/context",
+ json={"task": "x" * 501},
+ headers=headers,
+ )
+ self.assertEqual(too_large.status_code, 422)
+
+ invalid_budget = self.client.post(
+ "/v1/context",
+ json={"task": "bounded", "token_budget": 100_001},
+ headers=headers,
+ )
+ self.assertEqual(invalid_budget.status_code, 422)
+
+ invalid_format = self.client.post(
+ "/v1/context",
+ json={"task": "bounded", "format": "xml"},
+ headers=headers,
+ )
+ self.assertEqual(invalid_format.status_code, 422)
+
def test_capture_get_invalid_token_returns_unauthorized_page(self) -> None:
response = self.client.get("/capture", params={"token": "wrong-token", "content": "Remember this."})
@@ -171,22 +228,32 @@ def test_privacy_lifecycle_report_exposes_delete_and_backup_contract(self) -> No
self.assertIn("trust_score", payload["ai_access"])
self.assertIn("events", payload["audit"])
- def test_health_exposes_sharding_contract(self) -> None:
+ def test_health_exposes_only_safe_sharding_status(self) -> None:
response = self.client.get("/health")
self.assertEqual(response.status_code, 200)
payload = response.json()
sharding = payload["sharding"]
self.assertEqual(sharding["mode"], "local")
- self.assertEqual(sharding["default"]["shard_id"], "local")
- self.assertIn("db_path", sharding["default"])
- hosted_readiness = payload["hosted_readiness"]
- self.assertEqual(hosted_readiness["status"], "ok")
- self.assertFalse(hosted_readiness["hosted_mode"])
- self.assertEqual(hosted_readiness["shard_mode"], "local")
- self.assertFalse(hosted_readiness["require_scoped_api_tokens"])
- self.assertEqual(hosted_readiness["global_token_user_switching"], "allowed_local_compatibility")
- self.assertEqual(hosted_readiness["checks"][0]["name"], "scoped_api_tokens_required")
+ self.assertEqual(sharding["default_shard_id"], "local")
+ self.assertNotIn("db_path", json.dumps(payload))
+ self.assertNotIn("vault_path", json.dumps(payload))
+ self.assertNotIn("hosted_readiness", payload)
+
+ def test_hosted_health_defers_tenant_wide_readiness_scan(self) -> None:
+ original_settings = main_module.settings
+ main_module.settings = replace(original_settings, shard_mode="bucket")
+ try:
+ with patch(
+ "backend.app.main._cached_credential_encryption_evidence",
+ side_effect=AssertionError("liveness must not scan tenant credentials"),
+ ):
+ response = self.client.get("/health")
+ finally:
+ main_module.settings = original_settings
+
+ self.assertEqual(response.status_code, 200)
+ self.assertNotIn("hosted_readiness", response.json())
def test_ready_requires_scoped_api_tokens_for_hosted_shard_modes(self) -> None:
original_settings = main_module.settings
@@ -196,15 +263,10 @@ def test_ready_requires_scoped_api_tokens_for_hosted_shard_modes(self) -> None:
self.assertEqual(response.status_code, 503)
detail = response.json()["detail"]
- self.assertEqual(detail["status"], "needs_configuration")
- hosted_readiness = detail["hosted_readiness"]
- self.assertEqual(hosted_readiness["status"], "blocked")
- self.assertTrue(hosted_readiness["hosted_mode"])
- self.assertEqual(hosted_readiness["shard_mode"], "bucket")
- self.assertFalse(hosted_readiness["require_scoped_api_tokens"])
- self.assertEqual(hosted_readiness["global_token_user_switching"], "blocked")
- self.assertEqual(hosted_readiness["checks"][0]["status"], "blocked")
- self.assertIn("CORTEX_REQUIRE_SCOPED_API_TOKENS=1", hosted_readiness["checks"][0]["detail"])
+ self.assertEqual(
+ detail,
+ {"status": "not_ready", "check": "hosted_configuration"},
+ )
finally:
main_module.settings = original_settings
@@ -413,6 +475,8 @@ def test_hosted_ready_requires_runtime_scoped_token_control_plane(self) -> None:
default_user_id="hosted-default",
shard_mode="bucket",
require_scoped_api_tokens=True,
+ require_encrypted_credentials=True,
+ legal_terms_approved=True,
public_base_url="https://api.cortex-hq.com",
sync_signing_key="sync-signing-key",
hosted_database_url="postgresql://cortex:secret@db.cortex.internal/cortex",
@@ -426,13 +490,26 @@ def test_hosted_ready_requires_runtime_scoped_token_control_plane(self) -> None:
)
main_module.settings = hosted_settings
main_module.store = main_module.StoreRegistry.from_settings(hosted_settings)
+ main_module.store.keyring = UserKeyring(
+ root / "keyring.sqlite",
+ LocalKekProvider(
+ env={
+ "CORTEX_KEK": base64.b64encode(bytes(range(32))).decode("ascii"),
+ }
+ ),
+ )
user = "hosted-ready-contract"
response = self.client.get("/ready")
self.assertEqual(response.status_code, 503)
+ self.assertEqual(
+ response.json()["detail"],
+ {"status": "not_ready", "check": "hosted_configuration"},
+ )
+ hosted_readiness = main_module._hosted_readiness_contract()
blocked = {
check["name"]
- for check in response.json()["detail"]["hosted_readiness"]["checks"]
+ for check in hosted_readiness["checks"]
if check["status"] == "blocked"
}
self.assertEqual(blocked, {"runtime_hosted_storage", "background_worker_queue", "control_plane_scoped_tokens"})
@@ -442,9 +519,10 @@ def test_hosted_ready_requires_runtime_scoped_token_control_plane(self) -> None:
main_module.store.ensure_mcp_token(split_user, "cxm_hosted_ready_split_mcp_token_123456789", label="Hosted MCP", scopes=["read"])
split_ready = self.client.get("/ready")
self.assertEqual(split_ready.status_code, 503)
+ hosted_readiness = main_module._hosted_readiness_contract()
split_blocked = {
check["name"]
- for check in split_ready.json()["detail"]["hosted_readiness"]["checks"]
+ for check in hosted_readiness["checks"]
if check["status"] == "blocked"
}
self.assertEqual(split_blocked, {"runtime_hosted_storage", "background_worker_queue", "control_plane_scoped_tokens"})
@@ -453,7 +531,7 @@ def test_hosted_ready_requires_runtime_scoped_token_control_plane(self) -> None:
ready = self.client.get("/ready")
self.assertEqual(ready.status_code, 503)
- hosted_readiness = ready.json()["detail"]["hosted_readiness"]
+ hosted_readiness = main_module._hosted_readiness_contract()
runtime_blocked = {
check["name"]
for check in hosted_readiness["checks"]
@@ -1029,6 +1107,23 @@ def test_context_engine_endpoint_and_mcp_tool(self) -> None:
bad_format = self.client.post("/v1/context", json={"task": "x", "format": "yaml"}, headers=headers)
self.assertEqual(bad_format.status_code, 422)
+ for method in ("get", "post"):
+ with self.subTest(method=method):
+ if method == "get":
+ invalid_session = self.client.get(
+ "/v1/context",
+ params={"task": "Atlas database decision", "pin": True, "session_id": "asess_missing"},
+ headers=headers,
+ )
+ else:
+ invalid_session = self.client.post(
+ "/v1/context",
+ json={"task": "Atlas database decision", "pin": True, "session_id": "asess_missing"},
+ headers=headers,
+ )
+ self.assertEqual(invalid_session.status_code, 422)
+ self.assertIn("Unknown agent session", invalid_session.json()["detail"])
+
# A READ-scoped API token can use the engine AND receives the identity layer — the
# distilled picture of the user is a read, which is the whole point of the product.
read_token = "cxa-context-read-token"
@@ -1044,8 +1139,23 @@ def test_context_engine_endpoint_and_mcp_tool(self) -> None:
headers={"Authorization": f"Bearer {read_token}", "X-Cortex-User": user},
)
self.assertEqual(scoped.status_code, 200)
- scoped_identity = next(layer for layer in scoped.json()["layers"] if layer["layer"] == "identity")
+ scoped_payload = scoped.json()
+ scoped_identity = next(layer for layer in scoped_payload["layers"] if layer["layer"] == "identity")
self.assertIsNone(scoped_identity.get("omitted"))
+ scoped_post = self.client.post(
+ "/v1/context",
+ json={"task": "Atlas database decision"},
+ headers={"Authorization": f"Bearer {read_token}", "X-Cortex-User": user},
+ )
+ self.assertEqual(scoped_post.status_code, 200)
+ scoped_post_payload = scoped_post.json()
+ scoped_post_identity = next(
+ layer for layer in scoped_post_payload["layers"] if layer["layer"] == "identity"
+ )
+ self.assertIsNone(scoped_post_identity.get("omitted"))
+ scoped_payload.pop("generated_at", None)
+ scoped_post_payload.pop("generated_at", None)
+ self.assertEqual(scoped_payload, scoped_post_payload)
# The MCP core tool round-trips through /mcp with the same engine.
mcp_token = "cxm-context-tool-token"
@@ -2031,6 +2141,32 @@ def test_scoped_api_token_prevents_user_header_impersonation_when_required(self)
)
self.assertEqual(scoped.status_code, 200)
+ tool_headers = {
+ "Authorization": f"Bearer {scoped_token}",
+ "X-Cortex-User": "alice",
+ }
+ tool_schema = self.client.get(
+ "/v1/tools/schema",
+ headers=tool_headers,
+ )
+ self.assertEqual(tool_schema.status_code, 200, tool_schema.text)
+ self.assertTrue(tool_schema.json()["schema"])
+ tool_call = self.client.post(
+ "/v1/tools/call",
+ json={"name": "search_memory", "arguments": {"query": "nothing"}},
+ headers=tool_headers,
+ )
+ self.assertEqual(tool_call.status_code, 200, tool_call.text)
+ self.assertEqual(tool_call.json()["tool"], "search_memory")
+
+ # The REST token remains invalid on the MCP transport.
+ mcp_rejected = self.client.post(
+ "/mcp",
+ json={"jsonrpc": "2.0", "id": 1, "method": "tools/list"},
+ headers=tool_headers,
+ )
+ self.assertEqual(mcp_rejected.status_code, 401)
+
# A read/write token still cannot pull the raw bulk dump (distilled reads are allowed).
export_blocked = self.client.get(
"/v1/export.json",
diff --git a/backend/tests/test_hosted_readiness.py b/backend/tests/test_hosted_readiness.py
index 243505ba..21fecfc6 100644
--- a/backend/tests/test_hosted_readiness.py
+++ b/backend/tests/test_hosted_readiness.py
@@ -21,6 +21,8 @@ def ready_hosted_settings(self, **overrides) -> Settings:
values = {
"shard_mode": "bucket",
"require_scoped_api_tokens": True,
+ "require_encrypted_credentials": True,
+ "legal_terms_approved": True,
"public_base_url": "https://api.cortex-hq.com",
"sync_signing_key": "sync-signing-key",
"hosted_database_url": "postgresql://cortex:secret@db.cortex.internal/cortex",
@@ -37,6 +39,14 @@ def ready_hosted_settings(self, **overrides) -> Settings:
def ready_runtime(self) -> dict:
return {
+ "credential_encryption": {
+ "enforcement_enabled": True,
+ "keyring_available": True,
+ "remaining_plaintext": 0,
+ "scan_complete": True,
+ "unreadable_files": 0,
+ "invalid_records": 0,
+ },
"control_plane": {
"active_api_tokens": 1,
"active_mcp_tokens": 1,
@@ -73,6 +83,7 @@ def test_hosted_mode_blocks_until_production_controls_are_configured(self) -> No
self.assertEqual(contract["global_token_user_switching"], "blocked")
blocked = {check["name"] for check in contract["checks"] if check["status"] == "blocked"}
self.assertIn("scoped_api_tokens_required", blocked)
+ self.assertIn("credential_encryption", blocked)
self.assertIn("public_base_url", blocked)
self.assertIn("sync_signing_key", blocked)
self.assertIn("embedding_provider", blocked)
@@ -96,6 +107,49 @@ def test_hosted_mode_still_blocks_without_runtime_control_plane_evidence(self) -
blocked = {check["name"] for check in contract["checks"] if check["status"] == "blocked"}
self.assertEqual(blocked, {"control_plane_scoped_tokens"})
+ def test_hosted_mode_blocks_when_credential_encryption_is_not_enforced(self) -> None:
+ contract = hosted_readiness_contract(
+ self.ready_hosted_settings(require_encrypted_credentials=False),
+ runtime=self.ready_runtime(),
+ )
+ check = next(
+ item for item in contract["checks"] if item["name"] == "credential_encryption"
+ )
+ self.assertEqual(check["status"], "blocked")
+ self.assertIn("CORTEX_REQUIRE_ENCRYPTED_CREDENTIALS=1", check["detail"])
+
+ def test_hosted_mode_blocks_when_keyring_is_unavailable(self) -> None:
+ runtime = self.ready_runtime()
+ runtime["credential_encryption"]["keyring_available"] = False
+ contract = hosted_readiness_contract(self.ready_hosted_settings(), runtime=runtime)
+ check = next(
+ item for item in contract["checks"] if item["name"] == "credential_encryption"
+ )
+ self.assertEqual(check["status"], "blocked")
+ self.assertIn("KEK-backed runtime keyring", check["detail"])
+
+ def test_hosted_mode_blocks_until_plaintext_credential_scan_is_clean(self) -> None:
+ runtime = self.ready_runtime()
+ runtime["credential_encryption"]["remaining_plaintext"] = 3
+ contract = hosted_readiness_contract(self.ready_hosted_settings(), runtime=runtime)
+ check = next(
+ item for item in contract["checks"] if item["name"] == "credential_encryption"
+ )
+ self.assertEqual(check["status"], "blocked")
+ self.assertIn("3 remaining plaintext credential", check["detail"])
+
+ def test_hosted_mode_blocks_incomplete_or_unreadable_credential_scan(self) -> None:
+ runtime = self.ready_runtime()
+ runtime["credential_encryption"]["scan_complete"] = False
+ runtime["credential_encryption"]["unreadable_files"] = 1
+ contract = hosted_readiness_contract(self.ready_hosted_settings(), runtime=runtime)
+ check = next(
+ item for item in contract["checks"] if item["name"] == "credential_encryption"
+ )
+ self.assertEqual(check["status"], "blocked")
+ self.assertIn("non-truncated", check["detail"])
+ self.assertIn("unreadable credential file", check["detail"])
+
def test_hosted_mode_blocks_worker_queue_attention_state(self) -> None:
runtime = self.ready_runtime()
runtime["worker_queue"] = {
@@ -213,6 +267,8 @@ def setUp(self) -> None:
public_base_url="https://api.cortex-hq.com",
shard_mode="bucket",
require_scoped_api_tokens=True,
+ require_encrypted_credentials=True,
+ legal_terms_approved=True,
sync_signing_key="sync-signing-key",
worker_mode="external",
observability_enabled=True,
@@ -222,6 +278,14 @@ def setUp(self) -> None:
def sqlite_runtime(self) -> dict:
return {
+ "credential_encryption": {
+ "enforcement_enabled": True,
+ "keyring_available": True,
+ "remaining_plaintext": 0,
+ "scan_complete": True,
+ "unreadable_files": 0,
+ "invalid_records": 0,
+ },
"control_plane": {
"active_api_tokens": 1,
"active_mcp_tokens": 1,
diff --git a/backend/tests/test_hosted_trust_boundaries.py b/backend/tests/test_hosted_trust_boundaries.py
new file mode 100644
index 00000000..fe82ec56
--- /dev/null
+++ b/backend/tests/test_hosted_trust_boundaries.py
@@ -0,0 +1,118 @@
+from __future__ import annotations
+
+from dataclasses import replace
+from pathlib import Path
+import tempfile
+import unittest
+
+from fastapi import HTTPException
+
+from backend.app import main as main_module
+
+
+class HostedTrustBoundaryTests(unittest.TestCase):
+ def setUp(self) -> None:
+ self.original_settings = main_module.settings
+ main_module.settings = replace(
+ self.original_settings,
+ shard_mode="user",
+ require_scoped_api_tokens=True,
+ )
+
+ def tearDown(self) -> None:
+ main_module.settings = self.original_settings
+
+ def test_hosted_mode_rejects_server_filesystem_operations(self) -> None:
+ with self.assertRaises(HTTPException) as caught:
+ main_module._require_local_filesystem_access("Path-based import")
+
+ self.assertEqual(caught.exception.status_code, 403)
+ self.assertIn("local mode", str(caught.exception.detail))
+
+ def test_hosted_mode_rejects_custom_credential_bearing_origin(self) -> None:
+ with self.assertRaises(HTTPException) as caught:
+ main_module._require_hosted_connector_origin("slack", "http://127.0.0.1:8080")
+
+ self.assertEqual(caught.exception.status_code, 422)
+ self.assertIn("Custom slack API origins", str(caught.exception.detail))
+
+ def test_hosted_mode_accepts_the_official_connector_origin(self) -> None:
+ main_module._require_hosted_connector_origin("slack", "https://slack.com/api/")
+ main_module._require_hosted_connector_origin("github", None)
+
+ def test_hosted_mode_accepts_only_official_oauth_token_endpoints(self) -> None:
+ main_module._require_hosted_oauth_token_endpoint(
+ "gmail",
+ "https://oauth2.googleapis.com/token",
+ )
+ main_module._require_hosted_oauth_token_endpoint("notion", None)
+
+ with self.assertRaises(HTTPException) as caught:
+ main_module._require_hosted_oauth_token_endpoint(
+ "outlook",
+ "http://169.254.169.254/latest/meta-data",
+ )
+ self.assertEqual(caught.exception.status_code, 422)
+
+ def test_hosted_jira_accepts_atlassian_cloud_only(self) -> None:
+ main_module._require_hosted_jira_cloud_origin("https://acme.atlassian.net")
+
+ for unsafe in (
+ "http://acme.atlassian.net",
+ "https://127.0.0.1",
+ "https://acme.example.com",
+ "https://user:pass@acme.atlassian.net",
+ "https://acme.atlassian.net:8443",
+ ):
+ with self.subTest(unsafe=unsafe), self.assertRaises(HTTPException):
+ main_module._require_hosted_jira_cloud_origin(unsafe)
+
+ def test_local_mode_preserves_desktop_and_test_overrides(self) -> None:
+ main_module.settings = replace(self.original_settings, shard_mode="local")
+
+ main_module._require_local_filesystem_access("Path-based import")
+ main_module._require_hosted_connector_origin("slack", "http://127.0.0.1:8080")
+ main_module._require_hosted_jira_cloud_origin("http://localhost:8080")
+
+ def test_sharded_store_enforces_policy_below_http_routes(self) -> None:
+ with tempfile.TemporaryDirectory() as tmp:
+ root = Path(tmp)
+ hosted_settings = replace(
+ self.original_settings,
+ db_path=root / "control.sqlite",
+ vault_path=root / "control.vault",
+ shard_root=root / "shards",
+ shard_mode="user",
+ require_scoped_api_tokens=True,
+ )
+ registry = main_module.StoreRegistry.from_settings(hosted_settings)
+ hosted_store = registry.store_for_user("alice")
+
+ self.assertTrue(hosted_store.hosted_mode)
+ with self.assertRaisesRegex(ValueError, "Custom slack API origins"):
+ hosted_store.sync_slack_account(
+ "alice",
+ token="xoxb-test",
+ channels=["C123"],
+ api_base_url="http://169.254.169.254/latest/meta-data",
+ )
+ with self.assertRaisesRegex(ValueError, "local mode"):
+ hosted_store.sync_obsidian_vault("alice", vault_path="/etc")
+ with self.assertRaisesRegex(ValueError, "local mode"):
+ hosted_store.sync_calendar_account("alice", feed_url="http://127.0.0.1/private.ics")
+ with self.assertRaisesRegex(ValueError, "OAuth token endpoints"):
+ hosted_store._source_credential_payload_with_fresh_oauth_token(
+ "alice",
+ {"id": "account-1", "source": "gmail"},
+ {
+ "access_token": "expired",
+ "access_token_expires_at": "2000-01-01T00:00:00Z",
+ "refresh_token": "refresh-secret",
+ "client_id": "client-id",
+ "token_endpoint": "http://169.254.169.254/latest/meta-data",
+ },
+ )
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/backend/tests/test_http_security.py b/backend/tests/test_http_security.py
new file mode 100644
index 00000000..f56c2562
--- /dev/null
+++ b/backend/tests/test_http_security.py
@@ -0,0 +1,70 @@
+from __future__ import annotations
+
+from email.message import Message
+from io import BytesIO
+import unittest
+from urllib.error import HTTPError
+from urllib.request import Request
+
+from backend.app.http_security import SameOriginRedirectHandler, _origin
+
+
+class SameOriginRedirectTests(unittest.TestCase):
+ def setUp(self) -> None:
+ self.handler = SameOriginRedirectHandler()
+ self.headers = Message()
+ self.fp = BytesIO()
+
+ def redirect(self, source: str, target: str) -> Request | None:
+ request = Request(
+ source,
+ headers={"Authorization": "Bearer secret", "Accept": "application/json"},
+ )
+ return self.handler.redirect_request(
+ request,
+ self.fp,
+ 302,
+ "Found",
+ self.headers,
+ target,
+ )
+
+ def test_allows_same_origin_absolute_and_relative_redirects(self) -> None:
+ absolute = self.redirect(
+ "https://api.example.com/v1/items",
+ "https://api.example.com/v2/items",
+ )
+ relative = self.redirect("https://api.example.com/v1/items", "/v2/items")
+
+ self.assertEqual(absolute.full_url, "https://api.example.com/v2/items")
+ self.assertEqual(relative.full_url, "https://api.example.com/v2/items")
+ self.assertEqual(absolute.get_header("Authorization"), "Bearer secret")
+
+ def test_treats_default_and_explicit_ports_as_the_same_origin(self) -> None:
+ redirected = self.redirect(
+ "https://api.example.com/v1/items",
+ "https://api.example.com:443/v2/items",
+ )
+ self.assertEqual(redirected.full_url, "https://api.example.com:443/v2/items")
+
+ def test_blocks_cross_host_port_and_scheme_redirects(self) -> None:
+ for target in (
+ "https://attacker.example/steal",
+ "https://api.example.com:8443/steal",
+ "http://api.example.com/steal",
+ "https://user:pass@api.example.com/steal",
+ ):
+ with self.subTest(target=target), self.assertRaisesRegex(
+ HTTPError,
+ "cross-origin redirect blocked",
+ ):
+ self.redirect("https://api.example.com/v1/items", target)
+
+ def test_rejects_malformed_or_credentialed_initial_origins(self) -> None:
+ self.assertIsNone(_origin("file:///tmp/private"))
+ self.assertIsNone(_origin("https://user:pass@example.com/data"))
+ self.assertIsNone(_origin("https://example.com:invalid/data"))
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/backend/tests/test_import_scale_bounds.py b/backend/tests/test_import_scale_bounds.py
index b6ed7b9a..12954473 100644
--- a/backend/tests/test_import_scale_bounds.py
+++ b/backend/tests/test_import_scale_bounds.py
@@ -10,6 +10,7 @@
from __future__ import annotations
import json
+import io
import tempfile
import unittest
import zipfile
@@ -19,7 +20,9 @@
MAX_TEXT_BYTES,
SourceAsset,
_assets_from_zip,
+ _assets_from_open_zip,
_format_csv_export,
+ _zip_declared_member_count,
_parse_mbox,
_parse_single_asset,
_parse_twitter_archive,
@@ -94,6 +97,65 @@ def test_zip_member_past_cap_is_truncated_not_skipped(self) -> None:
self.assertFalse(tiny.read_truncated)
self.assertEqual(tiny.read_bytes(), b"tiny note")
+ def test_zip_import_enforces_an_aggregate_uncompressed_byte_budget(self) -> None:
+ payload = io.BytesIO()
+ with zipfile.ZipFile(payload, "w") as archive:
+ archive.writestr("one.md", b"12345678")
+ archive.writestr("two.md", b"abcdefgh")
+ payload.seek(0)
+
+ with zipfile.ZipFile(payload) as archive:
+ assets = _assets_from_open_zip(
+ archive,
+ "bounded.zip",
+ depth=0,
+ member_budget=[10],
+ byte_budget=[10],
+ )
+
+ self.assertEqual([asset.name for asset in assets], ["one.md", "two.md"])
+ self.assertEqual(sum(len(asset.read_bytes()) for asset in assets), 10)
+ self.assertEqual(assets[0].read_bytes(), b"12345678")
+ self.assertEqual(assets[1].read_bytes(), b"ab")
+ self.assertTrue(assets[1].read_truncated)
+
+ def test_zip_member_budget_counts_directories_and_hidden_entries(self) -> None:
+ payload = io.BytesIO()
+ with zipfile.ZipFile(payload, "w") as archive:
+ archive.writestr("one/", b"")
+ archive.writestr(".hidden", b"secret")
+ archive.writestr("visible.md", b"note")
+ payload.seek(0)
+ member_budget = [3]
+
+ with zipfile.ZipFile(payload) as archive:
+ assets = _assets_from_open_zip(
+ archive,
+ "bounded.zip",
+ depth=0,
+ member_budget=member_budget,
+ byte_budget=[100],
+ )
+
+ self.assertEqual(member_budget, [0])
+ self.assertEqual([asset.name for asset in assets], ["visible.md"])
+
+ def test_zip_with_excessive_declared_members_is_rejected_before_import(self) -> None:
+ zip_path = self.root / "metadata-pressure.zip"
+ with zipfile.ZipFile(zip_path, "w") as archive:
+ for index in range(20_001):
+ archive.writestr(f"d{index}/", b"")
+
+ self.assertEqual(_assets_from_zip(zip_path), [])
+
+ def test_zip_member_preflight_ignores_eocd_marker_inside_comment(self) -> None:
+ payload = io.BytesIO()
+ with zipfile.ZipFile(payload, "w") as archive:
+ archive.writestr("visible.md", b"note")
+ archive.comment = b"comment-with-PK\x05\x06-marker"
+
+ self.assertEqual(_zip_declared_member_count(payload.getvalue()), 1)
+
class TwitterBatchingTests(unittest.TestCase):
"""The 1000-tweet cap is now a batch size: one record per 1000 tweets."""
diff --git a/backend/tests/test_macos_ui_quality_contract.py b/backend/tests/test_macos_ui_quality_contract.py
index 3758fa27..6513390a 100644
--- a/backend/tests/test_macos_ui_quality_contract.py
+++ b/backend/tests/test_macos_ui_quality_contract.py
@@ -55,8 +55,9 @@ def test_required_sign_in_wall_teaches_sources_and_ai_tool_setup(self) -> None:
self.assertIn("ScrollView", source)
def test_native_apple_button_is_full_width_and_entitlement_gated(self) -> None:
- # The Apple button renders whenever the build carries the applesignin entitlement
- # (canUseNativeAppleSignIn). It must NOT also depend on the /v1/auth/providers list:
+ # The shared Apple button renders whenever the build carries the applesignin
+ # entitlement (AppleSignInSupport.isAvailable). It must NOT also depend on the
+ # /v1/auth/providers list:
# native SIWA has no web client_secret, so that list deliberately never contains "apple",
# and gating on it made SIWA permanently dead code while GitHub/Google browser buttons
# rendered — the exact Guideline 4.8 violation. The native endpoint
@@ -64,10 +65,10 @@ def test_native_apple_button_is_full_width_and_entitlement_gated(self) -> None:
# no Apple client id, so the button is a real control, never a dead one.
source = CORTEX_CLOUD_AUTH.read_text(encoding="utf-8")
- self.assertIn("hasAppleSignInEntitlement", source)
+ self.assertIn("enum AppleSignInSupport", source)
self.assertIn("com.apple.developer.applesignin", source)
self.assertIn(".frame(maxWidth: .infinity, minHeight: 44, maxHeight: 44)", source)
- self.assertIn("if canUseNativeAppleSignIn {", source)
+ self.assertIn("if AppleSignInSupport.isAvailable {", source)
# 4.8 regression guard: SIWA must never again be gated on the web-provider list.
self.assertNotIn("canUseNativeAppleSignIn && backendOffersApple", source)
# Provider-aware sign-in: a real labeled button per configured provider (never a
diff --git a/backend/tests/test_memory_integrity.py b/backend/tests/test_memory_integrity.py
index 29f1b420..6ab3526f 100644
--- a/backend/tests/test_memory_integrity.py
+++ b/backend/tests/test_memory_integrity.py
@@ -4,10 +4,12 @@
import tempfile
import unittest
from pathlib import Path
+from unittest.mock import patch
from backend.app.database import connect, init_db
from backend.app.extractor import extract_context
-from backend.app.storage import CortexStore
+from backend.app import storage as storage_module
+from backend.app.storage import CortexStore, ExportSizeLimitError
class MemoryIntegrityTests(unittest.TestCase):
@@ -67,6 +69,34 @@ def test_empty_history_folds_to_genesis_only(self) -> None:
# With zero events the head is genesis folded zero times: exactly the genesis constant.
self.assertEqual(digest["chain_head"], self.store.INTEGRITY_CHAIN_GENESIS)
+ def test_synchronous_export_refuses_corpus_above_preflight_limit(self) -> None:
+ self.store.save_capture(
+ user_id=self.user_id,
+ content="large export evidence " * 200,
+ source="note",
+ source_url="note://large-export",
+ title=None,
+ extracted=extract_context("large export evidence"),
+ )
+ with patch.object(storage_module, "MAX_SYNCHRONOUS_EXPORT_BYTES", 100):
+ with self.assertRaises(ExportSizeLimitError):
+ self.store.export_json(self.user_id)
+
+ def test_export_preflight_is_conservative_for_non_bmp_unicode(self) -> None:
+ self.store.save_capture(
+ user_id=self.user_id,
+ content="😀" * 10_000,
+ source="note",
+ source_url="note://unicode-export",
+ title="Unicode",
+ extracted=extract_context("😀" * 10_000),
+ )
+ with connect(self.db_path) as conn:
+ estimated = self.store._estimated_export_bytes(conn, self.user_id)
+ payload = self.store.export_json(self.user_id)
+ canonical_bytes = len(self.store._canonical_export_bytes(payload))
+ self.assertGreaterEqual(estimated, canonical_bytes)
+
def test_new_event_advances_the_head(self) -> None:
ids = self._seed(3, approve=1)
before = self.store.integrity_digest(self.user_id)
diff --git a/backend/tests/test_oidc_registry.py b/backend/tests/test_oidc_registry.py
index b023eb77..0e896d8c 100644
--- a/backend/tests/test_oidc_registry.py
+++ b/backend/tests/test_oidc_registry.py
@@ -260,6 +260,26 @@ def test_happy_path_verifies_and_sends_pkce_verifier(self) -> None:
self.assertEqual(exchanges[0]["code"], "auth-code")
self.assertEqual(exchanges[0]["redirect_uri"], "https://app.example/callback")
+ def test_signup_consent_is_bound_to_and_consumed_with_oauth_state(self) -> None:
+ started = self.registry.start(
+ "google",
+ "https://app.example/callback",
+ signup_consent=True,
+ )
+ flow = self.store.get_flow(started["state"])
+ assert flow is not None
+ payload = json.loads(flow["payload_json"])
+ self.assertIs(payload["signup_consent"], True)
+ self.id_token_claims = self._claims(payload)
+
+ identity = self.registry.complete(
+ "google",
+ state=started["state"],
+ code="auth-code",
+ )
+
+ self.assertIs(identity["signup_consent"], True)
+
def test_state_is_single_use(self) -> None:
started, payload = self._start()
self.id_token_claims = self._claims(payload)
diff --git a/backend/tests/test_ops_readiness_check.py b/backend/tests/test_ops_readiness_check.py
index 66652b92..39d26917 100644
--- a/backend/tests/test_ops_readiness_check.py
+++ b/backend/tests/test_ops_readiness_check.py
@@ -154,6 +154,55 @@ def test_update_manifest_accepts_optional_obsidian_plugin_artifact(self) -> None
self.assertEqual(payload["version"], "0.1.0")
self.assertEqual({item["kind"] for item in payload["artifacts"]}, {"dmg", "zip", "obsidian-plugin"})
+ def test_update_manifest_allows_explicit_https_release_artifacts(self) -> None:
+ with tempfile.TemporaryDirectory() as tmp:
+ root = Path(tmp)
+ artifacts = [
+ {
+ "kind": kind,
+ "filename": filename,
+ "url": f"https://github.com/trace-cortex/releases/download/v1/{filename}",
+ "size_bytes": 123,
+ "sha256": "a" * 64,
+ }
+ for kind, filename in (
+ ("dmg", "Cortex-1.0.0-1.dmg"),
+ ("zip", "Cortex-1.0.0-1.app.zip"),
+ )
+ ]
+ manifest = {
+ "app": "Cortex",
+ "bundle_id": "com.cortex.doppl",
+ "channel": "stable",
+ "version": "1.0.0",
+ "build": "1",
+ "minimum_macos": "13.0",
+ "released_at": "2026-07-28T00:00:00Z",
+ "mandatory": False,
+ "release_notes": [],
+ "artifacts": artifacts,
+ }
+ manifest_path = root / "latest.json"
+ manifest_path.write_text(json.dumps(manifest), encoding="utf-8")
+
+ with self.assertRaises(FileNotFoundError):
+ validate_update_manifest(manifest_path)
+
+ payload = validate_update_manifest(manifest_path, allow_remote_artifacts=True)
+
+ self.assertEqual(payload["build"], "1")
+
+ manifest["artifacts"][0]["url"] = "http://downloads.example.test/Cortex-1.0.0-1.dmg"
+ manifest_path.write_text(json.dumps(manifest), encoding="utf-8")
+ with self.assertRaises(FileNotFoundError):
+ validate_update_manifest(manifest_path, allow_remote_artifacts=True)
+
+ manifest["artifacts"][0]["url"] = "https://downloads.example.test/Cortex-1.0.0-1.dmg"
+ manifest["artifacts"][0]["sha256"] = "not-a-digest"
+ manifest_path.write_text(json.dumps(manifest), encoding="utf-8")
+ with self.assertRaises(ValueError):
+ validate_update_manifest(manifest_path, allow_remote_artifacts=True)
+
def test_site_match_payload_can_skip_site_for_local_dmg_only_beta(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
diff --git a/backend/tests/test_phase2_context_packs.py b/backend/tests/test_phase2_context_packs.py
index 2a541fc0..765bb603 100644
--- a/backend/tests/test_phase2_context_packs.py
+++ b/backend/tests/test_phase2_context_packs.py
@@ -141,8 +141,17 @@ def test_session_linked_pins_are_listable_by_session(self) -> None:
self.assertEqual(replay["session_id"], session["id"])
def test_pin_with_unknown_session_fails_loudly(self) -> None:
- with self.assertRaises(ValueError):
+ with connect(self.db_path) as conn:
+ events_before = int(conn.execute("SELECT COUNT(*) FROM memory_events").fetchone()[0])
+ working_before = int(conn.execute("SELECT COUNT(*) FROM session_working_set").fetchone()[0])
+ with self.assertRaisesRegex(ValueError, "Unknown agent session"):
self._pin(session_id="asess_missing")
+ with connect(self.db_path) as conn:
+ events_after = int(conn.execute("SELECT COUNT(*) FROM memory_events").fetchone()[0])
+ working_after = int(conn.execute("SELECT COUNT(*) FROM session_working_set").fetchone()[0])
+ self.assertEqual(working_after, working_before)
+ self.assertEqual(events_after, events_before)
+ self.assertEqual(self._call("list_context_packs", {}), [])
# -- substrate invariants ---------------------------------------------------------------------
diff --git a/backend/tests/test_public_pages.py b/backend/tests/test_public_pages.py
index ad98cf76..f59c2bb6 100644
--- a/backend/tests/test_public_pages.py
+++ b/backend/tests/test_public_pages.py
@@ -54,7 +54,10 @@ def test_legal_pages_have_real_content(self) -> None:
self.assertIn("16 years", terms) # age gate matches the signup ToS checkbox
privacy = self.client.get("/privacy").text
self.assertIn("Privacy Policy", privacy)
- self.assertIn("crypto-shred", privacy) # reflects the real deletion behavior
+ self.assertIn("Connector credentials are", privacy)
+ self.assertIn("encrypted at rest", privacy)
+ self.assertIn("removes your hosted memory", privacy)
+ self.assertNotIn("whole-memory crypto", privacy.lower())
def test_download_points_at_the_public_notarized_artifact(self) -> None:
# The download button must hand out the Developer ID signed + notarized DMG from the
diff --git a/backend/tests/test_ratelimit.py b/backend/tests/test_ratelimit.py
index b5ee5dc5..dafcc748 100644
--- a/backend/tests/test_ratelimit.py
+++ b/backend/tests/test_ratelimit.py
@@ -46,6 +46,52 @@ def test_reset_clears_bucket(self) -> None:
limiter.reset("alice")
self.assertTrue(limiter.check("alice")[0])
+ def test_bucket_map_fails_closed_instead_of_resetting_active_keys(self) -> None:
+ limiter = TokenBucketRateLimiter(
+ 60,
+ burst=1,
+ max_buckets=3,
+ idle_ttl_seconds=100,
+ time_fn=lambda: 0.0,
+ )
+ for key in ("a", "b", "c"):
+ self.assertTrue(limiter.check(key)[0])
+ self.assertFalse(limiter.check("d")[0])
+ self.assertEqual(list(limiter._buckets), ["a", "b", "c"])
+ # Flooding novel keys cannot evict and reset a key that has exhausted
+ # its bucket.
+ self.assertFalse(limiter.check("a")[0])
+
+ def test_idle_buckets_are_evicted_before_active_buckets(self) -> None:
+ clock = {"t": 0.0}
+ limiter = TokenBucketRateLimiter(
+ 60,
+ burst=1,
+ max_buckets=3,
+ idle_ttl_seconds=10,
+ time_fn=lambda: clock["t"],
+ )
+ limiter.check("old-a")
+ limiter.check("old-b")
+ clock["t"] = 11.0
+ limiter.check("new")
+ self.assertEqual(list(limiter._buckets), ["new"])
+
+ def test_fully_refilled_bucket_is_reclaimable_before_idle_ttl(self) -> None:
+ clock = {"t": 0.0}
+ limiter = TokenBucketRateLimiter(
+ 60,
+ burst=1,
+ max_buckets=1,
+ idle_ttl_seconds=600,
+ time_fn=lambda: clock["t"],
+ )
+ self.assertTrue(limiter.check("finished")[0])
+ self.assertFalse(limiter.check("new")[0])
+ clock["t"] = 1.0
+ self.assertTrue(limiter.check("new")[0])
+ self.assertEqual(list(limiter._buckets), ["new"])
+
if __name__ == "__main__":
unittest.main()
diff --git a/backend/tests/test_rerank_eval.py b/backend/tests/test_rerank_eval.py
index 92ba1296..38514fb8 100644
--- a/backend/tests/test_rerank_eval.py
+++ b/backend/tests/test_rerank_eval.py
@@ -11,8 +11,12 @@ def test_no_regression_under_model2vec(self):
prev = os.environ.get("CORTEX_EMBEDDING_PROVIDER")
os.environ["CORTEX_EMBEDDING_PROVIDER"] = "model2vec"
try:
- from backend.app.embeddings import embedding_status
+ from backend.app.embeddings import embedding_status, warmup_embedding_provider
+ # Probe the configured provider before deciding whether this environment
+ # can run the semantic floor. Without warmup, status may still report the
+ # requested provider even though the optional package/model cannot load.
+ warmup_embedding_provider()
if embedding_status().get("provider") != "model2vec":
self.skipTest("model2vec embedder unavailable in this environment")
from scripts.rerank_eval import run_rerank_eval
diff --git a/backend/tests/test_retrieval_quality.py b/backend/tests/test_retrieval_quality.py
index 530fed1c..a7507767 100644
--- a/backend/tests/test_retrieval_quality.py
+++ b/backend/tests/test_retrieval_quality.py
@@ -586,6 +586,77 @@ def test_lexical_fallback_does_not_pad_existing_strict_results(self) -> None:
self.assertEqual([item["id"] for item in results], ["strict_taipei_positioning"])
+ def test_owner_and_release_paraphrases_beat_explicit_non_answer_bait(self) -> None:
+ query = "Who is accountable for the Project Aster launch?"
+ self.store._vector_ready = lambda conn: False
+ self.assertEqual(
+ self.store._lexical_fallback_terms(query),
+ ["owner", "project", "aster", "release"],
+ )
+
+ for order_name, canonical_first in (
+ ("canonical-first", True),
+ ("bait-first", False),
+ ):
+ with self.subTest(order=order_name):
+ user_id = f"adversarial-paraphrase-{order_name}"
+ self.store.update_settings(
+ user_id,
+ {
+ "review_new_captures": False,
+ "allow_pending_in_context": True,
+ },
+ )
+ canonical_id = f"aster_canonical_{order_name}"
+ bait_id = f"aster_bait_{order_name}"
+ canonical = {
+ "id": canonical_id,
+ "kind": "claim",
+ "layer": "semantic",
+ "content": "Project Aster's rollout DRI is Mina Chen.",
+ "summary": "Mina Chen is the DRI for the Project Aster rollout.",
+ "confidence": "confirmed",
+ "importance": 3,
+ "topics": ["project-aster", "rollout", "dri"],
+ "entity_ids": [],
+ }
+ bait = {
+ "id": bait_id,
+ "kind": "claim",
+ "layer": "semantic",
+ "content": (
+ "Project Aster launch accountability template. This "
+ "unassigned template does not name an owner and must not "
+ "be used to answer who is accountable."
+ ),
+ "summary": "An unassigned Project Aster accountability template.",
+ "confidence": "confirmed",
+ "importance": 3,
+ "topics": ["project-aster", "launch", "accountability"],
+ "entity_ids": [],
+ }
+ records = [canonical, bait] if canonical_first else [bait, canonical]
+ self.store.save_capture(
+ user_id=user_id,
+ content="\n".join(str(record["content"]) for record in records),
+ source="adversarial-retrieval-test",
+ source_url="https://example.invalid/project-aster",
+ title="Project Aster ownership fixture",
+ extracted={
+ "_timestamp": "2026-07-30T00:00:00Z",
+ "summary": "Project Aster ownership fixture.",
+ "records": records,
+ "tasks": [],
+ "entities": [],
+ },
+ )
+
+ results = self.store.search(user_id, query, limit=5)
+
+ self.assertTrue(results)
+ self.assertEqual(results[0]["id"], canonical_id)
+ self.assertIn(canonical_id, [item["id"] for item in results])
+
def test_lexical_fallback_applies_recency_and_importance_boosts(self) -> None:
self.store.update_settings(self.user_id, {"review_new_captures": False, "allow_pending_in_context": True})
self.store._vector_ready = lambda conn: False
diff --git a/backend/tests/test_standalone_server.py b/backend/tests/test_standalone_server.py
index 072f1b82..176c03dd 100644
--- a/backend/tests/test_standalone_server.py
+++ b/backend/tests/test_standalone_server.py
@@ -5,6 +5,7 @@
import tempfile
import threading
import unittest
+from dataclasses import replace
from unittest import mock
from pathlib import Path
from urllib import error, request
@@ -250,6 +251,10 @@ def context_pack(self, user_id: str, *, query: str = "", limit: int = 12, sector
def assemble_context(self, user_id: str, task: str = "", **kwargs) -> dict | str:
call = {"user_id": user_id, "task": task, **kwargs}
self.assemble_context_calls.append(call)
+ if kwargs.get("pin") and kwargs.get("session_id") == "asess_missing":
+ raise standalone_server.UnknownAgentSessionError(
+ "Unknown agent session; call start_agent_session first (ids look like asess_...)."
+ )
if kwargs.get("format") == "markdown":
return "# Cortex Context Pack\n\nassembled"
return {
@@ -549,6 +554,17 @@ def export_manifest(self, user_id: str) -> dict:
"record_counts": {"captures": 3, "memories": 2, "tasks": 0, "entities": 0, "edges": 0, "imports": 0},
}
+ def export_json(self, user_id: str) -> dict:
+ return {
+ "user_id": user_id,
+ "captures": [],
+ "memories": [],
+ "tasks": [],
+ "entities": [],
+ "edges": [],
+ "imports": [],
+ }
+
def export_portable_bundle(self, user_id: str) -> dict:
self.export_portable_bundle_calls.append(user_id)
return {
@@ -638,7 +654,9 @@ def health_payload(self, *, mode: str, auth: bool) -> dict:
"auth": auth,
"sharding": {
"mode": "local",
- "default": {"shard_id": "local", "db_path": "/tmp/index.sqlite", "vault_path": "/tmp/Cortex.vault"},
+ "shard_count": 1,
+ "active_store_count": 1,
+ "default_shard_id": "local",
},
}
@@ -2860,6 +2878,19 @@ def test_oversized_request_body_returns_413(self) -> None:
request.urlopen(req, timeout=5)
self.assertEqual(ctx.exception.code, 413)
+ def test_oversized_synchronous_export_returns_413_not_500(self) -> None:
+ headers = {"Authorization": "Bearer test-token"}
+ with mock.patch.object(
+ self.fake_store,
+ "export_json",
+ side_effect=standalone_server.ExportSizeLimitError("export is too large"),
+ ):
+ req = request.Request(self.base_url + "/v1/export.json", headers=headers)
+ with self.assertRaises(error.HTTPError) as ctx:
+ request.urlopen(req, timeout=5)
+ self.assertEqual(ctx.exception.code, 413)
+ self.assertIn("export is too large", ctx.exception.read().decode("utf-8"))
+
def test_standalone_worker_enabled_only_for_local_inline_mode(self):
with mock.patch.dict(os.environ, {}, clear=True):
standalone_server.settings = Settings(
@@ -2887,6 +2918,39 @@ def test_standalone_worker_enabled_only_for_local_inline_mode(self):
with mock.patch.dict(os.environ, {"CORTEX_STANDALONE_WORKER_ENABLED": "1"}):
self.assertTrue(standalone_server._standalone_worker_enabled())
+ def test_standalone_server_refuses_non_loopback_bind_by_default(self):
+ # Wildcard and public hosts are literal rejection cases, never bind targets.
+ wildcard_host = "0.0.0.0" # nosec B104
+ for host in (wildcard_host, "::", "192.0.2.10", "public.example"):
+ with self.subTest(host=host), mock.patch.dict(os.environ, {}, clear=True):
+ with self.assertRaisesRegex(ValueError, "local-only"):
+ standalone_server.serve(host, 9876)
+
+ def test_standalone_server_refuses_sample_token_on_non_loopback_bind(self):
+ original_settings = standalone_server.settings
+ standalone_server.settings = replace(
+ original_settings,
+ api_key=standalone_server.INSECURE_DEV_API_KEY,
+ )
+ try:
+ with mock.patch.dict(
+ os.environ,
+ {"CORTEX_ALLOW_NONLOCAL_STANDALONE": "1"},
+ clear=True,
+ ):
+ with self.assertRaisesRegex(ValueError, "sample token"):
+ standalone_server.serve("0.0.0.0", 9876) # nosec B104
+ finally:
+ standalone_server.settings = original_settings
+
+ def test_standalone_loopback_detection_handles_ipv4_ipv6_and_mapped_addresses(self):
+ for host in ("localhost", "127.0.0.1", "127.9.8.7", "::1", "::ffff:127.0.0.1"):
+ with self.subTest(host=host):
+ self.assertTrue(standalone_server._standalone_host_is_loopback(host))
+ # This literal validates that wildcard binding is not classified as loopback.
+ wildcard_host = "0.0.0.0" # nosec B104
+ self.assertFalse(standalone_server._standalone_host_is_loopback(wildcard_host))
+
def test_standalone_worker_tick_drains_memory_jobs_without_source_scheduling(self):
result = standalone_server._run_standalone_worker_tick(limit=7)
self.assertEqual(result["processed"], 1)
@@ -3092,27 +3156,23 @@ def test_cors_echoes_configured_local_origin(self) -> None:
self.assertEqual(headers.get("Access-Control-Allow-Origin"), "http://localhost:8766")
self.assertEqual(headers.get("Vary"), "Origin")
- def test_health_and_ready_expose_hosted_readiness_contract(self) -> None:
+ def test_health_and_ready_expose_only_public_probe_status(self) -> None:
with self.get("/health") as response:
health = json.loads(response.read().decode("utf-8"))
self.assertEqual(response.status, 200)
self.assertEqual(health["mode"], "standalone")
- hosted_readiness = health["hosted_readiness"]
- self.assertEqual(hosted_readiness["status"], "ok")
- self.assertFalse(hosted_readiness["hosted_mode"])
- self.assertEqual(hosted_readiness["shard_mode"], "local")
- self.assertFalse(hosted_readiness["require_scoped_api_tokens"])
- self.assertEqual(hosted_readiness["global_token_user_switching"], "allowed_local_compatibility")
- self.assertEqual(hosted_readiness["checks"][0]["name"], "scoped_api_tokens_required")
+ self.assertNotIn("db_path", json.dumps(health))
+ self.assertNotIn("vault_path", json.dumps(health))
+ self.assertNotIn("hosted_readiness", health)
with self.get("/ready") as response:
ready = json.loads(response.read().decode("utf-8"))
self.assertEqual(response.status, 200)
self.assertEqual(ready["status"], "ok")
- self.assertEqual(ready["hosted_readiness"]["status"], "ok")
- self.assertEqual(ready["diagnostics"]["status"], "ok")
+ self.assertEqual(ready["backend_version"], standalone_server.BACKEND_VERSION)
+ self.assertNotIn("diagnostics", ready)
def test_ready_requires_scoped_api_tokens_for_hosted_shard_modes(self) -> None:
standalone_server.settings = Settings(
@@ -3128,22 +3188,16 @@ def test_ready_requires_scoped_api_tokens_for_hosted_shard_modes(self) -> None:
health = json.loads(response.read().decode("utf-8"))
self.assertEqual(response.status, 200)
- hosted_readiness = health["hosted_readiness"]
- self.assertEqual(hosted_readiness["status"], "blocked")
- self.assertTrue(hosted_readiness["hosted_mode"])
- self.assertEqual(hosted_readiness["shard_mode"], "bucket")
- self.assertFalse(hosted_readiness["require_scoped_api_tokens"])
- self.assertEqual(hosted_readiness["global_token_user_switching"], "blocked")
+ self.assertNotIn("hosted_readiness", health)
with self.assertRaises(error.HTTPError) as context:
self.get("/ready")
self.assertEqual(context.exception.code, 503)
detail = json.loads(context.exception.read().decode("utf-8"))["detail"]
- self.assertEqual(detail["status"], "needs_configuration")
- hosted_readiness = detail["hosted_readiness"]
- self.assertEqual(hosted_readiness["status"], "blocked")
- self.assertEqual(hosted_readiness["checks"][0]["status"], "blocked")
- self.assertIn("CORTEX_REQUIRE_SCOPED_API_TOKENS=1", hosted_readiness["checks"][0]["detail"])
+ self.assertEqual(
+ detail,
+ {"status": "not_ready", "check": "hosted_configuration"},
+ )
def test_delete_capture_forwards_to_store(self) -> None:
with self.delete("/v1/captures/capture-1") as response:
@@ -4597,6 +4651,47 @@ def test_scoped_api_token_prevents_user_header_impersonation_when_required(self)
self.assertEqual(payload["results"][0]["content"], "Layer-aware result")
self.assertEqual(self.fake_store.search_calls[-1]["user_id"], "alice")
+ api_headers = {
+ "Authorization": "Bearer cxa-standalone-token",
+ "X-Cortex-User": "alice",
+ }
+ with request.urlopen(
+ request.Request(
+ self.base_url + "/v1/tools/schema",
+ headers=api_headers,
+ ),
+ timeout=5,
+ ) as response:
+ self.assertEqual(response.status, 200)
+ self.assertTrue(json.loads(response.read().decode("utf-8"))["schema"])
+
+ tool_request = request.Request(
+ self.base_url + "/v1/tools/call",
+ data=json.dumps(
+ {"name": "search_memory", "arguments": {"query": "voice"}}
+ ).encode("utf-8"),
+ headers={**api_headers, "Content-Type": "application/json"},
+ method="POST",
+ )
+ with request.urlopen(tool_request, timeout=5) as response:
+ tool_payload = json.loads(response.read().decode("utf-8"))
+ self.assertEqual(response.status, 200)
+ self.assertEqual(tool_payload["tool"], "search_memory")
+
+ with self.assertRaises(error.HTTPError) as context:
+ request.urlopen(
+ request.Request(
+ self.base_url + "/mcp",
+ data=json.dumps(
+ {"jsonrpc": "2.0", "id": 1, "method": "tools/list"}
+ ).encode("utf-8"),
+ headers={**api_headers, "Content-Type": "application/json"},
+ method="POST",
+ ),
+ timeout=5,
+ )
+ self.assertEqual(context.exception.code, 401)
+
# A token without export scope is still denied the raw bulk dump (distilled reads like
# context-pack are now allowed with read scope).
with self.assertRaises(error.HTTPError) as context:
@@ -4817,7 +4912,34 @@ def test_context_engine_routes_on_shipping_server(self) -> None:
# Admin bearer sees the identity layer.
self.assertIs(call["include_identity"], True)
- # POST parity + markdown content type.
+ # A read-scoped token gets the same identity policy over GET and POST.
+ scoped_headers = {
+ "Authorization": "Bearer cxa-standalone-token",
+ "X-Cortex-User": "alice",
+ }
+ with request.urlopen(
+ request.Request(self.base_url + "/v1/context?task=scoped+get", headers=scoped_headers),
+ timeout=5,
+ ) as response:
+ self.assertEqual(response.status, 200)
+ scoped_get = self.fake_store.assemble_context_calls[-1]
+ self.assertIs(scoped_get["include_identity"], True)
+
+ with request.urlopen(
+ request.Request(
+ self.base_url + "/v1/context",
+ data=json.dumps({"task": "scoped post"}).encode("utf-8"),
+ headers={**scoped_headers, "Content-Type": "application/json"},
+ method="POST",
+ ),
+ timeout=5,
+ ) as response:
+ self.assertEqual(response.status, 200)
+ scoped_post = self.fake_store.assemble_context_calls[-1]
+ self.assertIs(scoped_post["include_identity"], True)
+ self.assertIn(("alice", "read"), self.fake_store.require_agent_access_calls)
+
+ # Admin POST parity + markdown content type.
with request.urlopen(
request.Request(
self.base_url + "/v1/context",
@@ -4835,6 +4957,38 @@ def test_context_engine_routes_on_shipping_server(self) -> None:
self.post_json("/v1/context", {"task": "x", "format": "yaml"})
self.assertEqual(context.exception.code, 422)
+ for invalid in (
+ {"task": {"nested": "object"}},
+ {"task": "x", "token_budget": 0},
+ {"task": "x", "pin": {"truthy": True}},
+ {"task": "x", "format": {"not": "text"}},
+ ):
+ with self.subTest(invalid=invalid), self.assertRaises(error.HTTPError) as context:
+ self.post_json("/v1/context", invalid)
+ self.assertEqual(context.exception.code, 422)
+
+ with self.post_json("/v1/context", {"task": "x", "pin": "false"}) as response:
+ self.assertEqual(response.status, 200)
+ self.assertIs(self.fake_store.assemble_context_calls[-1]["pin"], False)
+
+ for request_factory in (
+ lambda: request.Request(
+ self.base_url + "/v1/context?task=x&pin=true&session_id=asess_missing",
+ headers={"Authorization": "Bearer test-token"},
+ ),
+ lambda: request.Request(
+ self.base_url + "/v1/context",
+ data=json.dumps({"task": "x", "pin": True, "session_id": "asess_missing"}).encode("utf-8"),
+ headers={"Authorization": "Bearer test-token", "Content-Type": "application/json"},
+ method="POST",
+ ),
+ ):
+ request_value = request_factory()
+ with self.subTest(method=request_value.get_method()), self.assertRaises(error.HTTPError) as context:
+ request.urlopen(request_value, timeout=5)
+ self.assertEqual(context.exception.code, 422)
+ self.assertIn("Unknown agent session", context.exception.read().decode("utf-8"))
+
def test_context_pack_verify_route_on_shipping_server(self) -> None:
# Phase 2b: POST /v1/context/packs/{sha}/verify reaches store.verify_context_pack,
# is maintenance-scoped for API tokens, and maps ValueError -> 404.
diff --git a/backend/tests/test_turnstile.py b/backend/tests/test_turnstile.py
index 70c14275..fb4d0741 100644
--- a/backend/tests/test_turnstile.py
+++ b/backend/tests/test_turnstile.py
@@ -12,7 +12,7 @@
or invalid token is rejected (400/403) BEFORE the account is ever created; with
Turnstile disabled signup works with no token (no-op).
-The siteverify HTTP call is monkeypatched (urllib.request.urlopen) so no network
+The siteverify HTTP call is monkeypatched at the guarded transport boundary so no network
is touched; the argon2 signup path runs against the real FastAPI app in
auth-enabled hosted mode (same discipline as test_web_account / test_billing).
"""
@@ -26,7 +26,6 @@
import secrets
import tempfile
import unittest
-import urllib.request
from dataclasses import replace
from pathlib import Path
@@ -79,6 +78,7 @@ def setUp(self) -> None:
shard_mode="user",
default_user_id="hosted-default",
require_scoped_api_tokens=True,
+ legal_terms_approved=True,
auth_enabled=True,
accounts_db_path=None,
auth_email_mode="log",
@@ -134,7 +134,15 @@ def test_signup_page_has_no_widget_or_cf_csp(self) -> None:
def test_signup_succeeds_with_no_token(self) -> None:
email = "nobot@example.com"
- resp = self.client.post("/v1/auth/signup", json={"email": email, "password": PASSWORD})
+ resp = self.client.post(
+ "/v1/auth/signup",
+ json={
+ "email": email,
+ "password": PASSWORD,
+ "terms_accepted": True,
+ "age_confirmed": True,
+ },
+ )
self.assertEqual(resp.status_code, 200, resp.text)
self.assertTrue(self._account_exists(email))
@@ -147,11 +155,11 @@ class TurnstileEnabledTests(_HostedAppTestCase):
def setUp(self) -> None:
super().setUp()
- self._orig_urlopen = urllib.request.urlopen
+ self._orig_open_same_origin = main_module.open_same_origin
self._siteverify_calls: list = []
def tearDown(self) -> None:
- urllib.request.urlopen = self._orig_urlopen
+ main_module.open_same_origin = self._orig_open_same_origin
super().tearDown()
def _patch_siteverify(self, *, success: bool) -> None:
@@ -166,7 +174,7 @@ def fake_urlopen(req, *args, **kwargs):
payload["error-codes"] = ["invalid-input-response"]
return _FakeSiteverifyResponse(payload)
- urllib.request.urlopen = fake_urlopen
+ main_module.open_same_origin = fake_urlopen
# ----------------------------------------------------------- page markup
def test_settings_turnstile_enabled(self) -> None:
@@ -195,7 +203,13 @@ def test_valid_token_passes_signup(self) -> None:
email = "human@example.com"
resp = self.client.post(
"/v1/auth/signup",
- json={"email": email, "password": PASSWORD, "turnstile_token": "tok-good"},
+ json={
+ "email": email,
+ "password": PASSWORD,
+ "turnstile_token": "tok-good",
+ "terms_accepted": True,
+ "age_confirmed": True,
+ },
)
self.assertEqual(resp.status_code, 200, resp.text)
self.assertTrue(self._account_exists(email))
@@ -208,7 +222,15 @@ def test_valid_token_passes_signup(self) -> None:
def test_missing_token_rejected_before_signup(self) -> None:
self._patch_siteverify(success=True) # would pass if reached
email = "notoken@example.com"
- resp = self.client.post("/v1/auth/signup", json={"email": email, "password": PASSWORD})
+ resp = self.client.post(
+ "/v1/auth/signup",
+ json={
+ "email": email,
+ "password": PASSWORD,
+ "terms_accepted": True,
+ "age_confirmed": True,
+ },
+ )
self.assertEqual(resp.status_code, 400, resp.text)
# No account created, and siteverify never called (rejected before hash).
self.assertFalse(self._account_exists(email))
@@ -219,7 +241,13 @@ def test_invalid_token_rejected_before_signup(self) -> None:
email = "badtoken@example.com"
resp = self.client.post(
"/v1/auth/signup",
- json={"email": email, "password": PASSWORD, "turnstile_token": "tok-bad"},
+ json={
+ "email": email,
+ "password": PASSWORD,
+ "turnstile_token": "tok-bad",
+ "terms_accepted": True,
+ "age_confirmed": True,
+ },
)
self.assertEqual(resp.status_code, 403, resp.text)
self.assertFalse(self._account_exists(email))
@@ -230,11 +258,17 @@ def test_siteverify_network_error_fails_closed(self) -> None:
def boom(req, *a, **k):
raise OSError("network down")
- urllib.request.urlopen = boom
+ main_module.open_same_origin = boom
email = "offline@example.com"
resp = self.client.post(
"/v1/auth/signup",
- json={"email": email, "password": PASSWORD, "turnstile_token": "tok"},
+ json={
+ "email": email,
+ "password": PASSWORD,
+ "turnstile_token": "tok",
+ "terms_accepted": True,
+ "age_confirmed": True,
+ },
)
self.assertEqual(resp.status_code, 403, resp.text)
self.assertFalse(self._account_exists(email))
diff --git a/backend/tests/test_vault_concurrency.py b/backend/tests/test_vault_concurrency.py
index a3e9688b..b09aefce 100644
--- a/backend/tests/test_vault_concurrency.py
+++ b/backend/tests/test_vault_concurrency.py
@@ -1,14 +1,38 @@
from __future__ import annotations
import json
+import fcntl
+import multiprocessing
import tempfile
import threading
+import time
import unittest
from pathlib import Path
from backend.app.vault import CortexVault
+def _multiprocess_vault_writer(
+ root: str,
+ index_path: str,
+ worker_id: int,
+ writes: int,
+ start: multiprocessing.synchronize.Event,
+) -> None:
+ vault = CortexVault(Path(root), Path(index_path))
+ start.wait(timeout=10)
+ for offset in range(writes):
+ sequence = worker_id * writes + offset
+ vault.write_settings(f"process-user-{sequence}", {"sequence": sequence})
+ vault.write_source_credential(
+ user_id="process-user",
+ source_account_id=f"process-account-{sequence}",
+ source="github",
+ payload={"token": f"secret-{sequence}"},
+ )
+ vault.append_event({"user_id": "process-user", "type": "process-test", "seq": sequence})
+
+
class VaultConcurrencyTests(unittest.TestCase):
"""The shipping server is multi-threaded (one PID) and in bucket mode many tenants share one
vault. These tests guard the two concurrency hazards the round-2 review found: (a) a pid-only
@@ -113,6 +137,102 @@ def test_concurrent_append_events_none_lost(self) -> None:
events = [e for e in self.vault.iter_events() if e.get("type") == "test"]
self.assertEqual(len(events), count, "lost or corrupted event lines under concurrent append")
+ def test_multiprocess_read_modify_write_preserves_all_records(self) -> None:
+ process_count = 4
+ writes_per_process = 16
+ expected = process_count * writes_per_process
+ context = multiprocessing.get_context("spawn")
+ start = context.Event()
+ processes = [
+ context.Process(
+ target=_multiprocess_vault_writer,
+ args=(
+ str(self.vault.root),
+ str(self.vault.index_path),
+ worker_id,
+ writes_per_process,
+ start,
+ ),
+ )
+ for worker_id in range(process_count)
+ ]
+ for process in processes:
+ process.start()
+ start.set()
+ for process in processes:
+ process.join(timeout=30)
+ self.assertFalse(process.is_alive(), "vault writer process did not terminate")
+ self.assertEqual(process.exitcode, 0)
+
+ settings = json.loads(self.vault.settings_path.read_text(encoding="utf-8"))
+ credentials = json.loads(self.vault.credentials_path.read_text(encoding="utf-8"))
+ events = [
+ event for event in self.vault.iter_events()
+ if event.get("type") == "process-test"
+ ]
+ self.assertEqual(len(settings.get("users") or {}), expected)
+ self.assertEqual(
+ len(((credentials.get("users") or {}).get("process-user") or {})),
+ expected,
+ )
+ self.assertEqual(len(events), expected)
+ self.assertEqual({event["seq"] for event in events}, set(range(expected)))
+
+ def test_backup_shared_lock_freezes_ordinary_record_writes(self) -> None:
+ lock_path = self.vault.root / ".cortex-vault.lock"
+ started = threading.Event()
+ finished = threading.Event()
+
+ def write_record() -> None:
+ started.set()
+ self.vault.write_source_account(
+ {
+ "id": "source-during-backup",
+ "user_id": "u",
+ "source": "github",
+ }
+ )
+ finished.set()
+
+ with lock_path.open("a+b") as backup_lock:
+ fcntl.flock(backup_lock.fileno(), fcntl.LOCK_SH)
+ thread = threading.Thread(target=write_record)
+ thread.start()
+ self.assertTrue(started.wait(timeout=1))
+ time.sleep(0.05)
+ self.assertFalse(finished.is_set(), "vault write bypassed the backup lock")
+ fcntl.flock(backup_lock.fileno(), fcntl.LOCK_UN)
+ thread.join(timeout=2)
+
+ self.assertTrue(finished.is_set())
+
+ def test_backup_shared_lock_freezes_direct_markdown_pruning(self) -> None:
+ self.vault.write_daily_markdown({"date": "2026-07-30", "items": []})
+ page = self.vault.daily_markdown_path("2026-07-30")
+ self.assertTrue(page.exists())
+ lock_path = self.vault.root / ".cortex-vault.lock"
+ started = threading.Event()
+ finished = threading.Event()
+
+ def prune_page() -> None:
+ started.set()
+ self.vault.prune_daily_pages(set())
+ finished.set()
+
+ with lock_path.open("a+b") as backup_lock:
+ fcntl.flock(backup_lock.fileno(), fcntl.LOCK_SH)
+ thread = threading.Thread(target=prune_page)
+ thread.start()
+ self.assertTrue(started.wait(timeout=1))
+ time.sleep(0.05)
+ self.assertFalse(finished.is_set(), "Markdown prune bypassed the backup lock")
+ self.assertTrue(page.exists(), "page changed while the backup freeze was held")
+ fcntl.flock(backup_lock.fileno(), fcntl.LOCK_UN)
+ thread.join(timeout=2)
+
+ self.assertTrue(finished.is_set())
+ self.assertFalse(page.exists())
+
if __name__ == "__main__":
unittest.main()
diff --git a/backend/tests/test_vector_dim_migration.py b/backend/tests/test_vector_dim_migration.py
index a710202b..345ab620 100644
--- a/backend/tests/test_vector_dim_migration.py
+++ b/backend/tests/test_vector_dim_migration.py
@@ -1,5 +1,6 @@
from __future__ import annotations
+import json
import tempfile
import unittest
from pathlib import Path
@@ -37,6 +38,7 @@ def setUp(self) -> None:
self.user_id = "vec-user"
E._MODEL2VEC_MODEL = None
E._MODEL2VEC_MODEL_KEY = None
+ E._MODEL2VEC_DIMENSIONS_BY_KEY.clear()
E._MODEL2VEC_LOAD_FAILED = False
E._MODEL2VEC_FAILURE_LOGGED = False
# Create the user's settings once (persist in the DB; later stores reread them).
@@ -47,6 +49,7 @@ def setUp(self) -> None:
def tearDown(self) -> None:
E._MODEL2VEC_MODEL = None
E._MODEL2VEC_MODEL_KEY = None
+ E._MODEL2VEC_DIMENSIONS_BY_KEY.clear()
E._MODEL2VEC_LOAD_FAILED = False
E._MODEL2VEC_FAILURE_LOGGED = False
self._tmp.cleanup()
@@ -159,6 +162,58 @@ def test_switching_model_rebuilds_index_and_reembeds(self) -> None:
hits = store.search(self.user_id, "which datastore did we pick", limit=5)
self.assertTrue(any(h.get("id") == "mem_db" for h in hits), [h.get("id") for h in hits])
+ def test_same_dimension_model_swap_rebuilds_incompatible_vectors(self) -> None:
+ base_env = {
+ "CORTEX_EMBEDDING_PROVIDER": "openai",
+ "CORTEX_EMBEDDING_DIMENSIONS": str(E.VECTOR_DIMENSIONS),
+ }
+ with mock.patch.dict(
+ "os.environ",
+ {**base_env, "CORTEX_EMBEDDING_MODEL": "same-dim-model-a"},
+ clear=False,
+ ):
+ first = self._store()
+ self._seed(
+ first,
+ "mem_same_dimension_swap",
+ "Project Ember uses SQLite for its local datastore.",
+ ["project-ember", "database"],
+ )
+ with connect(self.db_path) as conn:
+ first._write_memory_vector(
+ conn,
+ memory_id="mem_same_dimension_swap",
+ user_id=self.user_id,
+ embedding_model="same-dim-model-a",
+ text_hash="fixture",
+ vector=json.dumps([0.0] * E.VECTOR_DIMENSIONS),
+ timestamp=now_iso(),
+ )
+ first_fingerprint = conn.execute(
+ "SELECT model FROM vec_index_meta WHERE id = 1"
+ ).fetchone()[0]
+ self.assertEqual(conn.execute("SELECT count(*) FROM memory_vec").fetchone()[0], 1)
+
+ with mock.patch.dict(
+ "os.environ",
+ {**base_env, "CORTEX_EMBEDDING_MODEL": "same-dim-model-b"},
+ clear=False,
+ ):
+ second = self._store()
+ with connect(self.db_path) as conn:
+ second_fingerprint = conn.execute(
+ "SELECT model FROM vec_index_meta WHERE id = 1"
+ ).fetchone()[0]
+ self.assertNotEqual(first_fingerprint, second_fingerprint)
+ self.assertEqual(conn.execute("SELECT count(*) FROM memory_vec").fetchone()[0], 0)
+ self.assertEqual(conn.execute("SELECT count(*) FROM memory_vec_map").fetchone()[0], 0)
+ self.assertGreater(
+ conn.execute(
+ "SELECT count(*) FROM memory_jobs WHERE job_type = 'embed_memory'"
+ ).fetchone()[0],
+ 0,
+ )
+
if __name__ == "__main__":
unittest.main()
diff --git a/backend/tests/test_web_account.py b/backend/tests/test_web_account.py
index 7a759b0b..75561bcb 100644
--- a/backend/tests/test_web_account.py
+++ b/backend/tests/test_web_account.py
@@ -63,6 +63,7 @@ def setUp(self) -> None:
shard_mode="user",
default_user_id="hosted-default",
require_scoped_api_tokens=True,
+ legal_terms_approved=True,
auth_enabled=True,
accounts_db_path=None,
auth_email_mode="log",
@@ -267,6 +268,11 @@ def test_signup_page_requires_tos_age_checkbox(self) -> None:
self.assertIn("/terms", html)
self.assertIn("/privacy", html)
self.assertIn('autocomplete="new-password"', html)
+ self.assertIn("/v1/auth/oauth/google/start?signup=1", html)
+ self.assertIn('class="button secondary oauth-signup"', html)
+ js = self.client.get("/account/app.js").text
+ self.assertIn("target.searchParams.set('terms_accepted', 'true')", js)
+ self.assertIn("target.searchParams.set('age_confirmed', 'true')", js)
def test_reset_page_points_at_reset_endpoints(self) -> None:
html = self.client.get("/account/reset").text
@@ -419,6 +425,8 @@ def test_oauth_callback_delivers_tokens_to_browser_in_url_fragment(self) -> None
"account": {"account_id": "acc_1", "status": "login", "primary_email": "signin@example.com"},
}
with mock.patch.object(runtime.oidc, "complete", return_value=identity), mock.patch.object(
+ runtime.control_store, "get_identity", return_value={"identity_id": "ident_1"}
+ ), mock.patch.object(
runtime.service, "find_or_challenge_identity", return_value=result
), mock.patch.object(runtime.service, "mint_session", return_value=session):
r = self.client.get(
diff --git a/deploy/README.md b/deploy/README.md
index e76c5e07..a51a2e01 100644
--- a/deploy/README.md
+++ b/deploy/README.md
@@ -27,27 +27,42 @@ hundreds of beta users fit comfortably here and the upgrade is a snapshot-restor
That uploads the current tree and runs `bootstrap.sh` on the box: OS hardening +
firewall, Caddy with automatic HTTPS, Python venv, secret generation (admin token,
- KEK, signing key — printed ONCE at the end; store them in a password manager),
- systemd services for the API + background worker, nightly WAL-safe backups, done.
+ KEK, signing key — stored in root-readable files and deliberately not printed),
+ systemd services for the API + background worker, nightly WAL-safe encrypted backups,
+ done.
4. **Verify:** `curl https://api.signindoppl.com/health` → `{"status":"ok",...}` and open
`https://api.signindoppl.com/ready`.
-5. **Escrow the KEK** (printed by bootstrap): copy `/etc/cortex/kek` into your password
- manager AND one offline place. If the box dies and the KEK is lost, every user's
- encrypted credentials are unrecoverable — that is the point of the design.
+5. **Escrow both recovery secrets** from an interactive, non-logged root session:
+ - `/etc/cortex/kek` decrypts stored connector credentials.
+ - `/etc/cortex/backup-age.key` decrypts backup archives.
+
+ Keep both in a password manager and offline recovery location, separately from the
+ backup bucket. Do not paste them into deployment logs or shell history. Losing either
+ can make a full restore impossible.
## What this beta configuration deliberately does
-- `CORTEX_AUTH_AUTOVERIFY=1`: accounts activate at signup with **no email server**.
- Flip it off (and set up Postmark + `CORTEX_AUTH_EMAIL_MODE=smtp`) before public launch —
- unverified emails mean no password-recovery channel.
+- `CORTEX_AUTH_AUTOVERIFY=0`: accounts stay pending until email verification. Configure
+ Postmark/SES (or another SMTP provider) before accepting public signups.
+- `CORTEX_LEGAL_TERMS_APPROVED=0`: public account creation and hosted readiness stay
+ blocked. Change this only after approved Terms and Privacy text is deployed; both
+ password and OAuth signup require explicit terms and age consent.
- GitHub login works the moment you create a (2-minute, no-review) GitHub OAuth app and
set the two env vars in `/etc/cortex/cortex.env`; Google login needs the consent-screen
publishing review (1–2 weeks) so leave it for later.
- Free tier only; no billing.
-- Backups are nightly, WAL-safe, kept 7 days **on the box** plus whatever Hetzner's
- VM backup snapshots. Add true offsite (rclone target in `backup.sh`) in week one.
+- Backups are nightly, WAL-safe, age-encrypted, and kept 7 days **on the box** plus
+ Hetzner VM snapshots. Set `BACKUP_RCLONE_REMOTE` for an offsite encrypted copy.
+
+To inspect a recovery archive on a clean machine:
+
+```bash
+mkdir restore
+age --decrypt -i backup-age.key cortex-YYYYMMDD-HHMMSS.tar.gz.age \
+ | tar -xz -C restore
+```
## Updating the running backend
diff --git a/deploy/backup.sh b/deploy/backup.sh
index 38358efc..f6adc58e 100755
--- a/deploy/backup.sh
+++ b/deploy/backup.sh
@@ -1,23 +1,78 @@
#!/usr/bin/env bash
# Nightly Cortex backup: WAL-safe `.backup` of every SQLite database (raw copies of live
# WAL files are NOT consistent), plus vault/attachment trees. Keeps 7 nights locally.
-# The KEK is deliberately NOT included — key escrow is separate from data backups by
-# design (docs/ACCOUNTS_ENCRYPTION_DESIGN.md): a stolen backup must stay unreadable.
+# Archives are encrypted to an age recipient; the private identity and Cortex KEK are
+# deliberately separate from the backup data.
#
# Offsite (recommended in week one): set BACKUP_RCLONE_REMOTE in /etc/cortex/cortex.env
# (e.g. "r2:cortex-backups") after `rclone config`; each archive is then copied off-box.
set -euo pipefail
+umask 077
SHARD_ROOT="${CORTEX_SHARD_ROOT:-/var/lib/cortex/shards}"
DEST_ROOT="/var/lib/cortex/backups"
-STAMP="$(date -u +%Y%m%d-%H%M%S)"
-STAGING="$DEST_ROOT/staging-$STAMP"
-ARCHIVE="$DEST_ROOT/cortex-$STAMP.tar.gz"
+STAMP="$(date -u +%Y%m%d-%H%M%S)-$$"
+SERVICE_USER="${CORTEX_SERVICE_USER:-cortex}"
-mkdir -p "$STAGING"
-trap 'rm -rf "$STAGING"' EXIT
+if [ -z "${BACKUP_AGE_RECIPIENT:-}" ]; then
+ echo "BACKUP_AGE_RECIPIENT is required; refusing to create a plaintext backup" >&2
+ exit 1
+fi
+if ! command -v age >/dev/null; then
+ echo "age is required to encrypt Cortex backups" >&2
+ exit 1
+fi
+if ! command -v flock >/dev/null; then
+ echo "flock is required to coordinate Cortex backups" >&2
+ exit 1
+fi
+if ! command -v sqlite3 >/dev/null || ! command -v tar >/dev/null || ! command -v runuser >/dev/null; then
+ echo "sqlite3, tar, and runuser are required to create Cortex backups" >&2
+ exit 1
+fi
+
+install -d -m 0700 "$DEST_ROOT"
+exec 9>"$DEST_ROOT/.backup.lock"
+if ! flock -n 9; then
+ echo "another Cortex backup is already running" >&2
+ exit 1
+fi
+STAGING="$(mktemp -d "$DEST_ROOT/.staging-$STAMP.XXXXXX")"
+ARCHIVE="$DEST_ROOT/cortex-$STAMP.tar.gz.age"
+ARCHIVE_TMP="$(mktemp "$DEST_ROOT/.archive-$STAMP.XXXXXX")"
+
+trap 'rm -rf -- "$STAGING"; rm -f -- "$ARCHIVE_TMP"' EXIT
+
+# 1. Vault trees (Markdown + JSON records + encrypted credential blobs). Memory content
+# is plaintext at rest, which is why the whole archive is encrypted below.
+# Snapshot vaults BEFORE the keyring/control databases: credential creation persists
+# its wrapped key before its encrypted vault blob. This order ensures a backed-up
+# ciphertext can never be newer than the backed-up keyring needed to decrypt it.
+while IFS= read -r -d '' vault; do
+ rel="${vault#"$SHARD_ROOT"/}"
+ target="$STAGING/vault/$rel"
+ mkdir -p "$target"
+ # Coordinate with CortexVault's cross-process writer lock. A shared lock
+ # freezes this vault while tar snapshots it and excludes the lock inode itself.
+ (
+ # Never open/chown this service-owned path as root: the service account can
+ # replace it with a symlink. Create and open it only with service-account
+ # privileges, so even a hostile path swap cannot alter a root-owned target.
+ if [ ! -e "$vault/.cortex-vault.lock" ]; then
+ runuser -u "$SERVICE_USER" -- touch -- "$vault/.cortex-vault.lock"
+ fi
+ if [ -L "$vault/.cortex-vault.lock" ] || [ ! -f "$vault/.cortex-vault.lock" ]; then
+ echo "unsafe vault lock path: $vault/.cortex-vault.lock" >&2
+ exit 1
+ fi
+ runuser -u "$SERVICE_USER" -- \
+ flock -s "$vault/.cortex-vault.lock" \
+ tar -C "$vault" --exclude='./.cortex-vault.lock' -cf - . \
+ | tar -C "$target" -xf -
+ )
+done < <(find "$SHARD_ROOT" -maxdepth 3 -type d -name '*.vault' -print0 2>/dev/null)
-# 1. Consistent snapshots of every SQLite DB under the shard root (shards, control
+# 2. Consistent snapshots of every SQLite DB under the shard root (shards, control
# registry, token index, accounts, keyring metadata) — .backup handles live WAL.
while IFS= read -r -d '' db; do
rel="${db#"$SHARD_ROOT"/}"
@@ -25,31 +80,24 @@ while IFS= read -r -d '' db; do
sqlite3 "$db" ".backup '$STAGING/db/$rel'"
done < <(find "$SHARD_ROOT" -name '*.sqlite' -print0 2>/dev/null)
-# 2. Vault trees (Markdown + JSON records + credentials blobs — credential payloads are
-# CXE1-encrypted at rest, so this archive stays safe without the KEK).
-while IFS= read -r -d '' vault; do
- rel="${vault#"$SHARD_ROOT"/}"
- mkdir -p "$STAGING/vault/$(dirname "$rel")"
- cp -R "$vault" "$STAGING/vault/$rel"
-done < <(find "$SHARD_ROOT" -maxdepth 3 -type d -name '*.vault' -print0 2>/dev/null)
-
-# 3. Config snapshot (env WITHOUT the KEK file).
-mkdir -p "$STAGING/etc"
-cp /etc/cortex/cortex.env "$STAGING/etc/cortex.env" 2>/dev/null || true
-
-tar -czf "$ARCHIVE" -C "$STAGING" .
+# Never copy /etc/cortex/cortex.env: it contains operator, OAuth, signing, and SMTP
+# credentials. Deployment configuration should be reconstructed from the example and
+# separately escrowed secrets.
+tar -cz -C "$STAGING" . | age -r "$BACKUP_AGE_RECIPIENT" -o "$ARCHIVE_TMP"
+mv "$ARCHIVE_TMP" "$ARCHIVE"
+chmod 0600 "$ARCHIVE"
echo "backup written: $ARCHIVE ($(du -h "$ARCHIVE" | cut -f1))"
-# 4. Retention: keep 7 nights locally.
-ls -1t "$DEST_ROOT"/cortex-*.tar.gz 2>/dev/null | tail -n +8 | xargs -r rm -f
+# 3. Retention: keep 7 nights locally.
+ls -1t "$DEST_ROOT"/cortex-*.tar.gz.age 2>/dev/null | tail -n +8 | xargs -r rm -f
-# 5. Optional offsite copy.
+# 4. Optional offsite copy. Only the encrypted archive leaves the machine.
if [ -n "${BACKUP_RCLONE_REMOTE:-}" ] && command -v rclone >/dev/null; then
rclone copy "$ARCHIVE" "$BACKUP_RCLONE_REMOTE/" --quiet
echo "offsite copy: $BACKUP_RCLONE_REMOTE/"
fi
-# 6. Optional dead-man ping (set BACKUP_PING_URL to a healthchecks.io check).
+# 5. Optional dead-man ping (set BACKUP_PING_URL to a healthchecks.io check).
if [ -n "${BACKUP_PING_URL:-}" ]; then
curl -fsS -m 10 "$BACKUP_PING_URL" >/dev/null || true
fi
diff --git a/deploy/bootstrap.sh b/deploy/bootstrap.sh
index 7ee6eda8..6c05568c 100755
--- a/deploy/bootstrap.sh
+++ b/deploy/bootstrap.sh
@@ -15,7 +15,7 @@ echo "=============================================================="
export DEBIAN_FRONTEND=noninteractive
apt-get update -qq
apt-get install -y -qq python3 python3-venv python3-pip ufw fail2ban unattended-upgrades \
- sqlite3 curl debian-keyring debian-archive-keyring apt-transport-https gnupg
+ sqlite3 curl age debian-keyring debian-archive-keyring apt-transport-https gnupg
id -u cortex >/dev/null 2>&1 || useradd --system --home /srv/cortex --shell /usr/sbin/nologin cortex
mkdir -p /srv/cortex/releases /var/lib/cortex/shards /var/lib/cortex/backups /etc/cortex
@@ -70,10 +70,29 @@ else
FIRST_INSTALL=0
fi
+# Backups use a dedicated public-key recipient. The private age identity is required
+# only for disaster recovery and must be escrowed separately from backup archives.
+BACKUP_AGE_IDENTITY=/etc/cortex/backup-age.key
+if [ ! -f "$BACKUP_AGE_IDENTITY" ]; then
+ age-keygen -o "$BACKUP_AGE_IDENTITY"
+ chown root:root "$BACKUP_AGE_IDENTITY"
+ chmod 0400 "$BACKUP_AGE_IDENTITY"
+ BACKUP_IDENTITY_CREATED=1
+else
+ BACKUP_IDENTITY_CREATED=0
+fi
+BACKUP_AGE_RECIPIENT="$(age-keygen -y "$BACKUP_AGE_IDENTITY")"
+if ! grep -q '^BACKUP_AGE_RECIPIENT=' "$ENV_FILE"; then
+ printf 'BACKUP_AGE_RECIPIENT=%s\n' "$BACKUP_AGE_RECIPIENT" >> "$ENV_FILE"
+fi
+
# --- 4. Python env + release switch -------------------------------------------
python3 -m venv /srv/cortex/venv 2>/dev/null || true
/srv/cortex/venv/bin/pip install --quiet --upgrade pip
-/srv/cortex/venv/bin/pip install --quiet -r "$RELEASE_DIR/backend/requirements.txt" -r "$RELEASE_DIR/backend/runtime-requirements.txt" uvicorn
+/srv/cortex/venv/bin/pip install --quiet --require-hashes \
+ -r "$RELEASE_DIR/backend/requirements.lock"
+/srv/cortex/venv/bin/pip install --quiet --require-hashes \
+ -r "$RELEASE_DIR/backend/runtime-requirements.lock"
ln -sfn "$RELEASE_DIR" /srv/cortex/current
chown -R cortex:cortex /srv/cortex/releases
@@ -99,11 +118,16 @@ curl -fsS "http://127.0.0.1:8766/health" -H "Authorization: Bearer $(grep '^CORT
echo "==> Public check (TLS may take ~30s on first issue): https://$API_DOMAIN/health"
echo "=============================================================="
-echo " DONE. Store these NOW (shown once):"
+echo " DONE."
if [ "$FIRST_INSTALL" = "1" ]; then
- echo " Admin token : $(grep '^CORTEX_API_KEY=' "$ENV_FILE" | cut -d= -f2)"
- echo " KEK (escrow offline + password manager): $(cat /etc/cortex/kek)"
+ echo " New admin token: stored in $ENV_FILE (not printed)"
+ echo " New KEK : stored in /etc/cortex/kek (not printed)"
+fi
+if [ "$BACKUP_IDENTITY_CREATED" = "1" ]; then
+ echo " Backup identity: stored in $BACKUP_AGE_IDENTITY (not printed)"
fi
+echo " Escrow the KEK and backup identity separately using an interactive,"
+echo " non-logged session. Do not copy them into deployment output."
echo " Env file : /etc/cortex/cortex.env"
echo " API : https://$API_DOMAIN (health/ready)"
echo " Services : systemctl status cortex-api cortex-worker caddy"
diff --git a/deploy/cortex.env.example b/deploy/cortex.env.example
index 0fa7cfb8..828a57cf 100644
--- a/deploy/cortex.env.example
+++ b/deploy/cortex.env.example
@@ -18,12 +18,15 @@ CORTEX_HOSTED_RUNTIME_TIER=sharded_sqlite
CORTEX_WORKER_MODE=external
CORTEX_SYNC_SIGNING_KEY=__SIGNING_KEY__
-# --- accounts + auth (BETA profile: signup without email infrastructure) ---
+# --- accounts + auth (public-safe default) ---
CORTEX_AUTH_ENABLED=1
-# Beta only: activate accounts at signup (no verification email). Turn OFF and set up
-# CORTEX_AUTH_EMAIL_MODE=smtp + a transactional provider before PUBLIC launch.
-CORTEX_AUTH_AUTOVERIFY=1
-CORTEX_AUTH_EMAIL_MODE=log
+# Deliberately blocks public signup and /ready until counsel-approved Terms
+# and Privacy text is deployed. Operators must review before changing to 1.
+CORTEX_LEGAL_TERMS_APPROVED=0
+# Keep accounts pending until their email address is verified. Configure SMTP below
+# before accepting public signups.
+CORTEX_AUTH_AUTOVERIFY=0
+CORTEX_AUTH_EMAIL_MODE=smtp
CORTEX_AUTH_RATE_LIMIT_PER_MINUTE=30
# GitHub login (works instantly once you create a GitHub OAuth app; callback URL:
# https://api.signindoppl.com/v1/auth/oauth/github/callback)
@@ -50,12 +53,23 @@ CORTEX_AUTH_RATE_LIMIT_PER_MINUTE=30
#CORTEX_SMTP_PASSWORD=
#CORTEX_SMTP_FROM_ADDR=Cortex
+# --- encrypted server backups ---
+# bootstrap.sh generates this public recipient and stores the private age identity
+# separately at /etc/cortex/backup-age.key. Never put the private identity here.
+#BACKUP_AGE_RECIPIENT=age1...
+#BACKUP_RCLONE_REMOTE=r2:cortex-backups
+
# --- per-user encryption (KEK generated by bootstrap; ESCROW IT OFFLINE) ---
CORTEX_KEK_FILE=/etc/cortex/kek
+CORTEX_REQUIRE_ENCRYPTED_CREDENTIALS=1
# --- fair use: non-zero ON PURPOSE (0 disables) ---
CORTEX_RATE_LIMIT_PER_MINUTE=120
CORTEX_DEFAULT_MEMORY_QUOTA=20000
+# Reject in-memory synchronous exports before they can exhaust an API worker.
+CORTEX_MAX_SYNC_EXPORT_BYTES=25000000
+# Each Argon2id operation uses 64 MiB. Two slots per API worker bound auth bursts.
+CORTEX_PASSWORD_HASH_CONCURRENCY=2
# --- retrieval quality: real on-device embeddings, zero API cost ---
CORTEX_EMBEDDING_PROVIDER=model2vec
diff --git a/deploy/macmini/README.md b/deploy/macmini/README.md
index 051449b5..d811fe8e 100644
--- a/deploy/macmini/README.md
+++ b/deploy/macmini/README.md
@@ -14,7 +14,8 @@ any router ports** via Cloudflare Tunnel.
genuinely fine; for a big public launch, weigh the uptime risk.
- **Backups MUST go off the machine.** The mini is the whole system now — if it
dies or is stolen, local backups die with it. Ship backups to R2/B2 (below).
-- **Escrow the KEK OFF the mini.** Same reason. (deploy/macmini/setup.sh prints it.)
+- **Escrow the KEK OFF the mini.** Same reason. The setup script stores it in a
+ protected file and deliberately does not print it.
- **ISP terms.** Cloudflare Tunnel is outbound-only (no inbound ports), which
sidesteps port-blocking, but some residential ISP ToS still discourage
"servers." Low-traffic betas are rarely an issue; know your ISP's rules.
@@ -24,8 +25,8 @@ From the repo on the mini:
```
bash deploy/macmini/setup.sh
```
-This installs deps, generates your admin token + encryption KEK (printed once —
-save them), writes `~/CortexServer/cortex.env`, and loads two launchd services
+This installs deps, generates your admin token + encryption KEK in protected
+files (they are deliberately not printed), writes `~/CortexServer/cortex.env`, and loads two launchd services
(`com.cortex.api`, `com.cortex.worker`) that start at login and restart on crash.
The API listens only on `127.0.0.1:8766` (never exposed directly).
@@ -71,10 +72,18 @@ Quick test without a domain/account: `cloudflared tunnel --url http://127.0.0.1:
prints a temporary `https://.trycloudflare.com` URL (ephemeral).
## Step 4 — Off-machine backups (do not skip)
-The bundled backup job keeps local copies; add an offsite target so a dead mini
-doesn't take the data with it. Easiest: a Backblaze B2 / Cloudflare R2 bucket +
-`rclone`, then a nightly launchd job running the same WAL-safe snapshot logic as
-`deploy/backup.sh` (adapt paths to `~/CortexServer`). Escrow the KEK separately.
+Install `age` (`brew install age`) and generate a recovery identity:
+
+```bash
+age-keygen -o ~/CortexServer/backup-age.key
+age-keygen -y ~/CortexServer/backup-age.key
+```
+
+Put the printed public recipient in the backup job as `BACKUP_AGE_RECIPIENT`; escrow
+the private identity and the Cortex KEK separately from the backup bucket. Add an
+offsite Backblaze B2 / Cloudflare R2 target with `rclone`, then run the WAL-safe
+`deploy/backup.sh` logic nightly (adapt paths to `~/CortexServer`). The script refuses
+to create plaintext archives.
## Step 5 — Point the app + web at it
Same as any hosted deployment: users open `https://api.signindoppl.com/account/signup`
diff --git a/deploy/macmini/setup.sh b/deploy/macmini/setup.sh
index 10b745f1..dd401249 100755
--- a/deploy/macmini/setup.sh
+++ b/deploy/macmini/setup.sh
@@ -6,15 +6,16 @@
#
# bash deploy/macmini/setup.sh
# Env overrides: CORTEX_DATA_DIR (default ~/CortexServer), CORTEX_PORT (8766),
-# CORTEX_PUBLIC_HOST (default api.trydoppl.com), CORTEX_SETUP_DRY_RUN=1 (write
-# files but don't launchctl load — for inspection/testing).
+# CORTEX_PUBLIC_HOST (default api.signindoppl.com),
+# CORTEX_PYTHON_BIN (default python3.12), CORTEX_SETUP_DRY_RUN=1 (write files
+# but don't launchctl load — for inspection/testing).
set -euo pipefail
REPO_DIR="$(cd "$(dirname "$0")/../.." && pwd)"
-PYTHON="$(command -v python3)"
DATA_DIR="${CORTEX_DATA_DIR:-$HOME/CortexServer}"
+VENV_DIR="$DATA_DIR/venv"
PORT="${CORTEX_PORT:-8766}"
-PUBLIC_HOST="${CORTEX_PUBLIC_HOST:-api.trydoppl.com}"
+PUBLIC_HOST="${CORTEX_PUBLIC_HOST:-api.signindoppl.com}"
ENV_FILE="$DATA_DIR/cortex.env"
KEK_FILE="$DATA_DIR/kek"
LOG_DIR="$DATA_DIR/logs"
@@ -22,6 +23,32 @@ AGENTS_DIR="$HOME/Library/LaunchAgents"
API_LABEL="com.cortex.api"
WORKER_LABEL="com.cortex.worker"
+PYTHON_BIN="${CORTEX_PYTHON_BIN:-python3.12}"
+if ! command -v "$PYTHON_BIN" >/dev/null 2>&1; then
+ echo "!! Cortex requires Python 3.12; '$PYTHON_BIN' was not found." >&2
+ echo " Install Python 3.12 or set CORTEX_PYTHON_BIN=/absolute/path/to/python3.12." >&2
+ exit 1
+fi
+PYTHON_BIN="$(command -v "$PYTHON_BIN")"
+PYTHON_VERSION="$("$PYTHON_BIN" -c 'import sys; print(f"{sys.version_info.major}.{sys.version_info.minor}")')"
+if [ "$PYTHON_VERSION" != "3.12" ]; then
+ echo "!! Cortex requires Python 3.12; '$PYTHON_BIN' reports Python $PYTHON_VERSION." >&2
+ exit 1
+fi
+
+mkdir -p "$DATA_DIR/shards" "$DATA_DIR/control" "$LOG_DIR" "$AGENTS_DIR"
+if [ ! -x "$VENV_DIR/bin/python" ]; then
+ echo "==> Creating dedicated Python environment at $VENV_DIR"
+ "$PYTHON_BIN" -m venv "$VENV_DIR"
+fi
+PYTHON="$VENV_DIR/bin/python"
+VENV_VERSION="$("$PYTHON" -c 'import sys; print(f"{sys.version_info.major}.{sys.version_info.minor}")')"
+if [ "$VENV_VERSION" != "3.12" ]; then
+ echo "!! Existing environment at $VENV_DIR uses Python $VENV_VERSION; expected 3.12." >&2
+ echo " Move that environment aside and rerun setup." >&2
+ exit 1
+fi
+
echo "=============================================================="
echo " Cortex Mac mini setup"
echo " repo: $REPO_DIR"
@@ -30,8 +57,6 @@ echo " data: $DATA_DIR"
echo " host: https://$PUBLIC_HOST (via Cloudflare Tunnel -> 127.0.0.1:$PORT)"
echo "=============================================================="
-mkdir -p "$DATA_DIR/shards" "$DATA_DIR/control" "$LOG_DIR" "$AGENTS_DIR"
-
# --- secrets, generated ONCE ---
if [ ! -f "$ENV_FILE" ]; then
ADMIN_TOKEN="cxop_$(openssl rand -hex 32)"
@@ -61,10 +86,11 @@ CORTEX_HOSTED_RUNTIME_TIER=sharded_sqlite
CORTEX_WORKER_MODE=external
CORTEX_SYNC_SIGNING_KEY=$SIGNING_KEY
-# accounts (BETA: signup works with no email server; set 0 + SMTP for public)
+# accounts (public-safe: configure SMTP before accepting signups)
CORTEX_AUTH_ENABLED=1
-CORTEX_AUTH_AUTOVERIFY=1
-CORTEX_AUTH_EMAIL_MODE=log
+CORTEX_LEGAL_TERMS_APPROVED=0
+CORTEX_AUTH_AUTOVERIFY=0
+CORTEX_AUTH_EMAIL_MODE=smtp
# BETA ergonomics: captures are immediately retrievable (skip the Review inbox) so a new
# user's first capture -> ask returns a cited answer without a manual approval step.
@@ -74,10 +100,13 @@ CORTEX_AUTO_APPROVE_CAPTURES=1
# per-user encryption
CORTEX_KEK_FILE=$KEK_FILE
+CORTEX_REQUIRE_ENCRYPTED_CREDENTIALS=1
# fair use (non-zero on purpose)
CORTEX_RATE_LIMIT_PER_MINUTE=120
CORTEX_DEFAULT_MEMORY_QUOTA=20000
+CORTEX_MAX_SYNC_EXPORT_BYTES=25000000
+CORTEX_PASSWORD_HASH_CONCURRENCY=2
# on-device embeddings (free, private)
CORTEX_EMBEDDING_PROVIDER=model2vec
@@ -100,13 +129,29 @@ ensure_env() {
fi
}
ensure_env CORTEX_AUTO_APPROVE_CAPTURES 1
+ensure_env CORTEX_REQUIRE_ENCRYPTED_CREDENTIALS 1
+ensure_env CORTEX_LEGAL_TERMS_APPROVED 0
+ensure_env CORTEX_PASSWORD_HASH_CONCURRENCY 2
+ensure_env CORTEX_MAX_SYNC_EXPORT_BYTES 25000000
+# Migrate only the former shipped default. Deliberate operator overrides are
+# preserved, while existing installs receive the safer in-memory export cap.
+if grep -q '^CORTEX_MAX_SYNC_EXPORT_BYTES=100000000$' "$ENV_FILE"; then
+ sed -i.bak \
+ 's/^CORTEX_MAX_SYNC_EXPORT_BYTES=100000000$/CORTEX_MAX_SYNC_EXPORT_BYTES=25000000/' \
+ "$ENV_FILE"
+ rm -f "$ENV_FILE.bak"
+ echo " lowered the former default CORTEX_MAX_SYNC_EXPORT_BYTES to 25000000"
+fi
chmod +x "$REPO_DIR/deploy/macmini/run-api.sh" "$REPO_DIR/deploy/macmini/run-worker.sh"
# --- Python deps ---
-echo "==> Installing backend dependencies into $PYTHON"
-"$PYTHON" -m pip install --quiet --upgrade pip
-"$PYTHON" -m pip install --quiet -r "$REPO_DIR/backend/requirements.txt" -r "$REPO_DIR/backend/runtime-requirements.txt" uvicorn || {
+echo "==> Installing backend dependencies into $VENV_DIR"
+"$PYTHON" -m pip install --quiet --require-hashes \
+ -r "$REPO_DIR/backend/requirements.lock" || {
+ echo "!! hosted dependency install failed; check $PYTHON"; exit 1; }
+"$PYTHON" -m pip install --quiet --require-hashes \
+ -r "$REPO_DIR/backend/runtime-requirements.lock" || {
echo "!! pip install failed; check $PYTHON"; exit 1; }
# --- launchd plists ---
@@ -170,9 +215,9 @@ done
echo "=============================================================="
if [ "${FIRST:-0}" = "1" ]; then
- echo " SAVE THESE NOW (shown once):"
- echo " Admin token : $(grep '^CORTEX_API_KEY=' "$ENV_FILE" | cut -d= -f2)"
- echo " KEK (escrow OFFLINE, NOT with backups): $(cat "$KEK_FILE")"
+ echo " New admin token: stored in $ENV_FILE (not printed)"
+ echo " New KEK : stored in $KEK_FILE (not printed)"
+ echo " Escrow the KEK offline from an interactive, non-logged session."
fi
echo " Services : launchctl list | grep com.cortex"
echo " Logs : $LOG_DIR/"
diff --git a/deploy/systemd/cortex-api.service b/deploy/systemd/cortex-api.service
index 6dcf12ef..bbbbed6d 100644
--- a/deploy/systemd/cortex-api.service
+++ b/deploy/systemd/cortex-api.service
@@ -15,12 +15,23 @@ ExecStart=/srv/cortex/venv/bin/uvicorn backend.app.main:app --host 127.0.0.1 --p
Restart=always
RestartSec=3
# Sandboxing
+UMask=0077
NoNewPrivileges=yes
ProtectSystem=strict
ProtectHome=yes
PrivateTmp=yes
+PrivateDevices=yes
+ProtectClock=yes
+ProtectControlGroups=yes
+ProtectKernelModules=yes
+ProtectKernelTunables=yes
+LockPersonality=yes
+RestrictRealtime=yes
+RestrictSUIDSGID=yes
+RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6
ReadWritePaths=/var/lib/cortex
MemoryMax=5G
+TasksMax=256
[Install]
WantedBy=multi-user.target
diff --git a/deploy/systemd/cortex-backup.service b/deploy/systemd/cortex-backup.service
index 697532ac..a1781dd6 100644
--- a/deploy/systemd/cortex-backup.service
+++ b/deploy/systemd/cortex-backup.service
@@ -4,5 +4,7 @@ Description=Cortex nightly backup (WAL-safe SQLite snapshots + vaults)
[Service]
Type=oneshot
User=root
+UMask=0077
+NoNewPrivileges=yes
EnvironmentFile=/etc/cortex/cortex.env
ExecStart=/usr/local/bin/cortex-backup
diff --git a/deploy/systemd/cortex-worker.service b/deploy/systemd/cortex-worker.service
index 85422baf..82b06f4b 100644
--- a/deploy/systemd/cortex-worker.service
+++ b/deploy/systemd/cortex-worker.service
@@ -11,12 +11,23 @@ WorkingDirectory=/srv/cortex/current
ExecStart=/srv/cortex/venv/bin/python scripts/run_memory_worker.py --iterations 0 --interval-seconds 30
Restart=always
RestartSec=5
+UMask=0077
NoNewPrivileges=yes
ProtectSystem=strict
ProtectHome=yes
PrivateTmp=yes
+PrivateDevices=yes
+ProtectClock=yes
+ProtectControlGroups=yes
+ProtectKernelModules=yes
+ProtectKernelTunables=yes
+LockPersonality=yes
+RestrictRealtime=yes
+RestrictSUIDSGID=yes
+RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6
ReadWritePaths=/var/lib/cortex
MemoryMax=2G
+TasksMax=256
[Install]
WantedBy=multi-user.target
diff --git a/deploy/update.sh b/deploy/update.sh
index f82974b8..e028c643 100755
--- a/deploy/update.sh
+++ b/deploy/update.sh
@@ -13,7 +13,10 @@ echo "==> Pre-deploy safety snapshot"
/usr/local/bin/cortex-backup || echo "!! backup failed (continuing; investigate after)"
echo "==> Installing requirements"
-/srv/cortex/venv/bin/pip install --quiet -r "$NEW_RELEASE/backend/requirements.txt" -r "$NEW_RELEASE/backend/runtime-requirements.txt" uvicorn
+/srv/cortex/venv/bin/pip install --quiet --require-hashes \
+ -r "$NEW_RELEASE/backend/requirements.lock"
+/srv/cortex/venv/bin/pip install --quiet --require-hashes \
+ -r "$NEW_RELEASE/backend/runtime-requirements.lock"
echo "==> Switching current -> $NEW_RELEASE"
ln -sfn "$NEW_RELEASE" /srv/cortex/current
diff --git a/docs/ACCOUNTS_ENCRYPTION_DESIGN.md b/docs/ACCOUNTS_ENCRYPTION_DESIGN.md
index 21ac9101..9121d7e9 100644
--- a/docs/ACCOUNTS_ENCRYPTION_DESIGN.md
+++ b/docs/ACCOUNTS_ENCRYPTION_DESIGN.md
@@ -229,14 +229,19 @@ No JWTs anywhere (OWASP: session state belongs server-side; revocation at 1M = o
One additive branch: after the global-key check, before the scoped-token path, `cxs_` prefix dispatches to `AccountsService.authenticate_session()` → returns the account's `user_id` with full user scopes but **never admin**. Session auth joins `accounts.status` AND `users.status` so suspension is enforced on every request. **CSRF enforced centrally in this dependency (graft, judge 3):** cookie-authenticated state-changing requests require the custom `X-Cortex-Client` header (plus `__Host-cortex_session` cookie: Secure, HttpOnly, SameSite=Lax) — no new endpoint can ship without the check. `last_seen_at` written at most once per 5 min per session.
### Endpoints (new `/v1/auth` router included from main.py)
-- `POST /v1/auth/signup` `{email, password}` → account `pending_verification` + verification mail (rate-limited + blocklist BEFORE hashing).
+- `POST /v1/auth/signup` `{email, password, terms_accepted, age_confirmed}` → account
+ `pending_verification` + verification mail (rate-limited + blocklist BEFORE hashing).
+ Hosted signup is unavailable until the operator marks reviewed legal text approved.
- `POST /v1/auth/verify-email` `{token}` → activate; **on activation, self-serve provisioning** calls the same internals as `StoreRegistry.provision_user` (sharding.py:819): `register_user` + lazy shard materialization + DEK creation — but does **not** auto-mint `cxa_`/`cxm_`.
- `POST /v1/auth/login` `{email, password}` → `{cxs_, cxr_, account}`; uniform generic 401 on any failure (no enumeration/timing oracle).
- `POST /v1/auth/refresh` (rotate; reuse ⇒ family revocation), `POST /v1/auth/logout` (revoke session), `GET /v1/auth/session` (whoami).
- `POST /v1/auth/password/reset/request` + `/confirm` — single-use hashed tokens in `auth_flows`, 30-min TTL.
- `GET /v1/auth/providers` — enabled `oidc_providers` rows for dynamic login buttons (the AI-vendor slot's UI half).
- `GET /v1/auth/oauth/{provider}/start` → authorize URL; state+nonce+PKCE verifier persisted in `auth_flows` (dedicated table, NOT the connector-flow `remember_oauth_pending` machinery — callback is unauthenticated).
-- `GET /v1/auth/oauth/{provider}/callback` → code exchange + full id_token verification → identity match ⇒ login; no match ⇒ `link_required` challenge or fresh signup per section 2 rules.
+- `GET /v1/auth/oauth/{provider}/callback` → code exchange + full id_token verification
+ → identity match ⇒ login; verified-email collision ⇒ `link_required`; unknown identity
+ ⇒ signup only when terms/age consent was bound into the single-use OAuth state by the
+ signup page. OAuth started from the login page never silently creates an account.
- `POST /v1/auth/oauth/{provider}/link` (session-authed) / `DELETE .../unlink` (refuse removing last method without a password).
- `POST /v1/auth/app/start` → `{flow_id, browser_url, poll_secret}`; macOS app opens `browser_url` in `ASWebAuthenticationSession`; app polls `POST /v1/auth/app/poll {flow_id, poll_secret}` until it gets the token pair (poll_secret salted-hashed in `auth_flows`; **no token ever rides a redirect URL**). Refresh token → Keychain. Email+password login from the app is a plain POST, no browser.
- `GET/POST/DELETE /v1/auth/tokens` — self-serve mint/list/revoke of the EXISTING `cxa_`/`cxm_` tokens via `create_api_token`/`create_mcp_token` (sharding.py:746/757), backfilling `scoped_token_index.account_id`. Sessions become the factory for PATs; the operator `/v1/admin/users` surface (main.py:1852) remains for support.
diff --git a/docs/ADDING_A_CONNECTOR.md b/docs/ADDING_A_CONNECTOR.md
new file mode 100644
index 00000000..87d99fbe
--- /dev/null
+++ b/docs/ADDING_A_CONNECTOR.md
@@ -0,0 +1,226 @@
+# Adding a Cortex Connector
+
+This guide separates two different jobs:
+
+1. **Send records from an existing integration.** Use the generic source-account
+ API. You do not need to modify Cortex.
+2. **Ship a first-party connector.** Add a source fetcher, credentials/setup
+ metadata, scheduled sync, API parity, UI wiring, and contract tests.
+
+Start with the generic API unless the integration must be configured and
+scheduled by Cortex itself.
+
+## Send One Cited Record Without Changing Cortex
+
+Start the development server:
+
+```bash
+CORTEX_AUTO_APPROVE_CAPTURES=0 make run
+```
+
+In another terminal, run this complete review-first workflow:
+
+```bash
+export CORTEX_BASE_URL="${CORTEX_BASE_URL:-http://127.0.0.1:8766}"
+export CORTEX_API_KEY=dev-local-key
+
+ACCOUNT_JSON="$(
+ curl --fail --silent --show-error \
+ -H "Authorization: Bearer $CORTEX_API_KEY" \
+ -H "Content-Type: application/json" \
+ -d '{
+ "source": "custom-notes",
+ "account_label": "Connector tutorial",
+ "account_identifier": "tutorial@example.invalid",
+ "connection_type": "api",
+ "status": "connected",
+ "auth_state": "healthy",
+ "policy": {"review_required": true, "allow_ai_context": true}
+ }' \
+ "$CORTEX_BASE_URL/v1/source-accounts"
+)"
+ACCOUNT_ID="$(
+ printf '%s' "$ACCOUNT_JSON" |
+ .venv/bin/python -c 'import json,sys; print(json.load(sys.stdin)["id"])'
+)"
+
+SYNC_JSON="$(
+ curl --fail --silent --show-error \
+ -H "Authorization: Bearer $CORTEX_API_KEY" \
+ -H "Content-Type: application/json" \
+ -d '{
+ "records": [{
+ "external_id": "tutorial-record-1",
+ "title": "Project Firefly launch",
+ "content": "Project Firefly launches on 15 August. Mina Chen is the DRI.",
+ "source_url": "https://example.invalid/tutorial/project-firefly",
+ "captured_at": "2026-07-30T12:00:00Z",
+ "metadata": {"kind": "project-note"}
+ }],
+ "processing": "sync",
+ "cursor_value": "tutorial-record-1"
+ }' \
+ "$CORTEX_BASE_URL/v1/source-accounts/$ACCOUNT_ID/sync"
+)"
+CAPTURE_ID="$(
+ printf '%s' "$SYNC_JSON" |
+ .venv/bin/python -c 'import json,sys; print(json.load(sys.stdin)["capture_ids"][0])'
+)"
+
+# Review-first invariant: the pending record must not be retrievable.
+curl --fail --silent --show-error --get \
+ -H "Authorization: Bearer $CORTEX_API_KEY" \
+ --data-urlencode "query=Who is the DRI for Project Firefly?" \
+ --data-urlencode "limit=5" \
+ "$CORTEX_BASE_URL/v1/search" |
+ .venv/bin/python -c '
+import json, sys
+base = "https://example.invalid/tutorial/project-firefly"
+results = json.load(sys.stdin).get("results", [])
+assert all(not str(item.get("source_url", "")).startswith(base) for item in results)
+print("pending capture is correctly excluded from retrieval")
+'
+
+curl --fail --silent --show-error -X POST \
+ -H "Authorization: Bearer $CORTEX_API_KEY" \
+ "$CORTEX_BASE_URL/v1/captures/$CAPTURE_ID/approve" >/dev/null
+
+curl --fail --silent --show-error --get \
+ -H "Authorization: Bearer $CORTEX_API_KEY" \
+ --data-urlencode "query=Who is the DRI for Project Firefly?" \
+ --data-urlencode "limit=5" \
+ "$CORTEX_BASE_URL/v1/ask" |
+ .venv/bin/python -m json.tool
+```
+
+The cited answer should include the original `source_url` as its base. Cortex
+may append a generated `#line=...&excerpt=...` locator so clients can open the
+specific cited passage. Keep `external_id` stable: re-sending the same source
+record updates its capture instead of duplicating it.
+
+## The Normalized Record Contract
+
+A connector converts vendor data into at most 500 records per sync request:
+
+| Field | Required | Rule |
+|---|---:|---|
+| `content` | yes | Plain, useful source text; maximum 200,000 characters |
+| `external_id` | strongly recommended | Stable and unique within the source account |
+| `title` | no | Human-readable source title |
+| `source_url` | no | Original deep link; Cortex creates a stable locator if absent |
+| `captured_at` | no | Source timestamp in ISO 8601 form |
+| `metadata` | no | Non-secret filtering and provenance fields |
+
+Never put access tokens, session cookies, authorization headers, or refresh
+tokens in record content, metadata, errors, logs, or fixtures.
+
+## First-Party Connector Architecture
+
+Use [`backend/app/connectors/raindrop.py`](../backend/app/connectors/raindrop.py)
+as the smallest token-based reference and
+[`backend/app/connectors/google_drive.py`](../backend/app/connectors/google_drive.py)
+as an OAuth/pagination reference.
+
+```mermaid
+flowchart LR
+ Setup["Connections UI or API setup"] --> Credentials["Local credential store"]
+ Scheduler["Sync scheduler"] --> Fetcher["Connector fetcher"]
+ Credentials --> Fetcher
+ Cursor["Durable sync cursor"] --> Fetcher
+ Fetcher --> Normalize["Normalized source records"]
+ Normalize --> Sync["CortexStore.sync_source_account_records"]
+ Sync --> Review["Review policy"]
+ Review --> Vault["Cited vault + rebuildable index"]
+```
+
+The fetcher is separate from persistence. It accepts credentials and cursor
+state, performs bounded read-only requests, and returns normalized records plus
+the next cursor. `CortexStore` owns deduplication, review policy, extraction,
+provenance, and vault writes.
+
+## Implementation Checklist
+
+### 1. Fetch and normalize
+
+- Add `backend/app/connectors/.py`.
+- Return immutable record and sync-result dataclasses with a
+ `to_source_account_record()` adapter.
+- Bound page size, pages per sync, total records, response size, and request
+ timeouts.
+- Preserve a stable vendor record ID, original deep link, and source timestamp.
+- Make pagination resumable. Do not advance the durable high-water mark until a
+ bounded scan completes.
+- Use `backend/app/http_security.py` for outbound requests and enforce the
+ official HTTPS origin. Redirects must not escape the allowed origin.
+- Redact credentials and authorization headers from every error with
+ `backend/app/connectors/_redaction.py`.
+
+### 2. Register the connector
+
+- Export the fetcher and record types from
+ `backend/app/connectors/__init__.py`.
+- Add setup/auth/readiness metadata to `CONNECTOR_SETUP_BLUEPRINTS` in
+ `backend/app/storage.py`.
+- Add or update the public connector catalog entry used by
+ `source_connector_catalog()`.
+- Add a `CortexStore` sync method that reads credentials, calls the fetcher,
+ passes records to `sync_source_account_records()`, and persists cursor/error
+ state.
+- Register scheduled dispatch in the source-sync scheduler. A manual sync and a
+ scheduled sync must call the same storage method.
+
+### 3. Preserve both HTTP runtimes
+
+- Add request/response fields to `backend/app/models.py` when the generic
+ contract is insufficient.
+- Add FastAPI routes in `backend/app/main.py`.
+- Add matching behavior in `backend/app/standalone_server.py`. The standalone
+ server is the runtime shipped in the macOS app; FastAPI-only support is
+ incomplete.
+- Add an MCP tool in `backend/app/mcp_tools.py` only if an AI client needs the
+ operation. Give it the narrowest capability (`read`, `write`, or
+ `maintenance`).
+
+### 4. Add the product setup path
+
+- Add the connector to the macOS Connections & Privacy catalog and setup flow.
+- Store secrets only through the existing credential/Keychain boundary.
+- Show required scopes before authorization, support disconnect/resume, and
+ surface a redacted `last_error`.
+- Keep file/export import as the fallback path when live authorization is not
+ release-ready.
+
+### 5. Prove the contract
+
+At minimum, tests must cover:
+
+- empty/malformed/oversized vendor responses;
+- pagination, cursor replay, and an interrupted scan;
+- stable IDs, unchanged-record deduplication, and changed-record replacement;
+- duplicate text with different external IDs;
+- citation/deep-link preservation;
+- credential and error redaction;
+- origin/redirect enforcement;
+- disconnect, resume, and scheduled sync;
+- FastAPI and standalone-server parity;
+- connector catalog and macOS UI visibility.
+
+Use deterministic fixtures and injected request functions. Tests must not call
+the real vendor service.
+
+```bash
+.venv/bin/python -m pytest backend/tests/test__connector.py -q
+.venv/bin/python scripts/check_connector_baseline.py
+.venv/bin/python scripts/examples_smoke.py --quickstart --runtime standalone
+make check
+```
+
+## Definition of Done
+
+A connector is ready only when a new user can discover it, understand its
+scopes, connect or disconnect it, complete an incremental sync, review the
+result, and retrieve a source-bearing citation. The same workflow must pass in
+the packaged standalone runtime and the FastAPI development runtime.
+
+If any of those pieces is intentionally absent, label the connector as
+experimental or export-only in the catalog instead of presenting it as ready.
diff --git a/docs/API_LIFECYCLE.md b/docs/API_LIFECYCLE.md
new file mode 100644
index 00000000..7ce50744
--- /dev/null
+++ b/docs/API_LIFECYCLE.md
@@ -0,0 +1,90 @@
+# API Lifecycle and Compatibility
+
+Status: **Current beta policy**
+
+Last reviewed: 2026-07-30
+
+## Supported Surfaces
+
+During beta, the compatibility contract covers only:
+
+- the `/v1/context`, `/v1/search`, `/v1/ask`, and `/v1/tools/*` HTTP surfaces
+ exercised by both SDK and contract tests;
+- the generated OpenAPI document at `/openapi.json`;
+- the Python package `doppl-cortex-client`;
+- the TypeScript package `@doppl-tech/cortex-client`;
+- the documented MCP tools and their JSON schemas.
+
+Other `/v1` routes are documented beta surfaces but do not yet carry a
+repository-wide compatibility guarantee. Internal Python modules, SQLite
+tables, vault implementation details, and routes without `/v1` are not stable
+extension points unless another current document explicitly says otherwise.
+Experimental branch documentation is not part of the released contract.
+
+## Versioning
+
+Cortex is currently beta. Additive response fields, new endpoints, new optional
+request fields, and new enum values may ship in a minor release. Consumers must
+ignore response fields they do not recognize.
+
+The API/SDK compatibility version is currently `0.1.0`; it is independent from
+the desktop application release version (`0.2.0`, build 51 at the time of this
+review). OpenAPI, MCP server metadata, and backend diagnostics all read the
+shared `BACKEND_VERSION` constant. Do not infer API compatibility from a DMG or
+Sparkle build number.
+
+A change is breaking when an existing valid request stops working, a field is
+removed or changes meaning/type, an authentication scope becomes insufficient,
+or an SDK method changes incompatibly. Breaking changes require one of:
+
+1. a new API namespace such as `/v2`;
+2. a new SDK major version; or
+3. a documented security emergency where preserving the old behavior would
+ leave user data or credentials exposed.
+
+Security tightening may reject requests that were never within the documented
+trust boundary, such as credential-bearing redirects to another origin.
+
+## Deprecation
+
+For non-emergency changes:
+
+1. mark the surface deprecated in the OpenAPI description, SDK docs, and
+ changelog;
+2. keep it functional for at least one published minor release;
+3. provide a replacement and migration example;
+4. remove it only in a major SDK release or a new API namespace.
+
+The HTTP response may include `Deprecation`, `Sunset`, and `Link` headers once a
+removal date is known. A deprecation is not complete if only a source-code
+comment announces it.
+
+## Contract Verification
+
+CI generates the OpenAPI schema and fails on duplicate operation IDs. Operation
+IDs are derived from the public HTTP method and path (for example,
+`post_v1_context`), never from a Python handler name, so internal refactors do
+not rename client operations. Contract tests also require the bearer security
+scheme and typed bounded request model on protected SDK surfaces. SDK tests pin
+method, path, authentication header, query, body, and same-origin redirect
+behavior for their supported calls. A proposed API change should update:
+
+- FastAPI and standalone-runtime contract tests where both expose the surface;
+- Python and TypeScript SDK tests;
+- the changelog and affected current documentation;
+- migration notes when compatibility is not additive.
+
+The generated schema is necessary but not sufficient: the loopback standard
+library runtime does not derive its routes from FastAPI, so parity requires
+explicit cross-runtime tests.
+
+A checked-in breaking-schema diff is still required before the API exits beta.
+Until then this policy protects the highest-use surfaces but is not evidence of
+complete historical compatibility.
+
+## Authentication and Redirects
+
+Clients must treat tokens as origin-bound credentials. First-party HTTP clients
+do not forward them to a different scheme, hostname, or effective port during a
+redirect. API deployments should use a stable canonical origin rather than
+depending on redirects.
diff --git a/docs/APPLE_RELEASE.md b/docs/APPLE_RELEASE.md
index e6fe53e3..eb4e094f 100644
--- a/docs/APPLE_RELEASE.md
+++ b/docs/APPLE_RELEASE.md
@@ -8,7 +8,7 @@ a packaging script.
|---|---|---|
| Packager | `macos/package_app_store.sh` | `macos/package_release.sh` |
| Sandbox | Yes (App Sandbox entitlement) | No |
-| Cloud account | No | Optional |
+| Account / sync | Sign-in required; memory sync over Swift `URLSession` | Sign-in required; sync optional |
| Outbound HTTPS connectors | No — TLS stripped from the bundle | Yes |
| MCP setup | Guided **manual** (no config writes) | **Automatic** config install |
| Distribution | App Store Connect review | Signed + notarized + stapled DMG/ZIP |
@@ -16,10 +16,11 @@ a packaging script.
## Hybrid strategy
-The Mac App Store build is **LOCAL-FIRST ONLY**. It is the sandbox-compliant,
-review-safe build:
+The Mac App Store build is **local-first and account-gated**. It is the
+sandbox-compliant, review-safe build:
-- No cloud account and no sign-in.
+- Required account sign-in and memory sync use the native Swift networking
+ layer. Local ingestion and retrieval still run inside the sandbox.
- No outbound HTTPS connectors. In `app-store` mode `macos/build.sh` strips
`_ssl*.so` and `ssl.py` from the bundled Python. `urllib`/`http.client` guard
`import ssl`, so the backend degrades cleanly to loopback/HTTP only. The local
@@ -31,9 +32,9 @@ review-safe build:
- All memory stays inside the app's sandbox container.
The notarized **Developer-ID DMG** is the full-featured power-user path: no
-sandbox, optional cloud, outbound HTTPS connectors available, and automatic MCP
-config install. It keeps `_ssl` and is built by `package_release.sh` — that path
-is byte-identical to before and is documented in the second half of this file.
+sandbox, required account sign-in, optional cloud sync, outbound HTTPS
+connectors, and automatic MCP config install. It keeps `_ssl` and is built by
+`package_release.sh` — that path is documented in the second half of this file.
Every App Store behavior difference is gated behind `DistributionMode.isAppStore`
(Swift, reads `CortexDistributionMode == "app-store"` from Info.plist) or the
@@ -185,7 +186,8 @@ communication):
- **No auto-MCP-connect** — MCP is set up via guided **manual** steps; the app
never writes into other apps' config files.
-- **No cloud** — no account, no sign-in.
+- **Account required** — sign-in and memory sync use native Swift networking;
+ connector networking in the bundled Python runtime remains disabled.
- **No HTTPS connectors** — the TLS stack (`_ssl`, `ssl.py`) is removed from the
bundle, so outbound HTTPS connectors are disabled.
- Persistent access to external, user-selected vault folders is disabled until
@@ -303,12 +305,14 @@ or Obsidian/local notes and complete one sync, approve one memory, ask Cortex an
verify a cited memory appears, create one backup, export one support bundle,
quit + relaunch, and verify the memory folder remains intact.
-## Current public release blockers (DMG path)
+## Current public release status (DMG path)
-- Developer ID signing
-- notarization
-- hosted HTTPS download domain
-- public privacy policy URL and support email
-- clean rollback archive for the previous DMG
-- final app icon and final landing/privacy copy review
-- test on a clean Mac that has never run the dev build
+The canonical public manifest, `site/downloads/latest.json`, records Cortex
+0.2.0 build 51 as Developer ID signed, notarized, and hosted on GitHub Releases.
+That statement applies only to the exact artifacts and SHA-256 values in the
+manifest.
+
+Cutting a later release still requires the signing identity, notary profile,
+clean-profile install test, rollback archive, checksum verification, and
+publication order described above. If any of those checks is unavailable, keep
+the candidate internal and do not replace the public manifest.
diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md
index 0bb04884..d7130e73 100644
--- a/docs/ARCHITECTURE.md
+++ b/docs/ARCHITECTURE.md
@@ -1,19 +1,66 @@
# Cortex Architecture
-## Current Prototype
+## Runtime Boundaries
+
+Cortex has two runtimes around one storage and retrieval core. The packaged
+macOS app starts a loopback-only standard-library server. The optional hosted
+plane uses FastAPI, account authentication, and per-user sharded stores.
+
+```mermaid
+flowchart LR
+ subgraph Desktop["Local desktop runtime"]
+ UI["SwiftUI app"]
+ LocalAPI["standalone_server.py 127.0.0.1 only"]
+ MCP["Scoped MCP proxy"]
+ end
+ subgraph Core["Shared Python core"]
+ Review["Capture + Review"]
+ Retrieval["Hybrid retrieval cite or abstain"]
+ Store["CortexStore"]
+ end
+ subgraph Data["User-owned local data"]
+ Vault["Markdown / JSON vault"]
+ Index["SQLite FTS5 + sqlite-vec"]
+ end
+ subgraph Hosted["Optional hosted account + sync plane"]
+ FastAPI["FastAPI"]
+ Registry["StoreRegistry per-user shards"]
+ Workers["Scheduled sync workers"]
+ end
+ UI --> LocalAPI
+ MCP --> LocalAPI
+ LocalAPI --> Review --> Store
+ LocalAPI --> Retrieval --> Store
+ Store <--> Vault
+ Store <--> Index
+ FastAPI --> Registry --> Store
+ Workers --> Registry
+```
+
+The desktop app and hosted plane are separate trust boundaries. A path supplied
+to the hosted API names a file on the server—not on the user's Mac—so
+filesystem connectors and path-based imports are local-only. Credential-bearing
+hosted connectors are restricted to their official service origins.
+
+## Legacy Prototype (Reference Only)
The original prototype has these pieces:
-- `capture.py`: macOS menu bar capture using Python
-- `ingest.py`: Claude-based extraction into records, tasks, and entities
-- `github_store.py`: markdown persistence in GitHub
-- `redis_store.py`: Voyage embeddings plus Redis vector search
-- `mcp_server.py`: legacy prototype local stdio MCP server; packaged builds use the app-bundled `scripts/cortex_mcp_stdio.py` proxy instead
-- `ui.py`: Streamlit memory chat
+- `legacy/capture.py`: macOS menu bar capture using Python
+- `legacy/ingest.py`: Claude-based extraction into records, tasks, and entities
+- `legacy/github_store.py`: markdown persistence in GitHub
+- `legacy/redis_store.py`: Voyage embeddings plus Redis vector search
+- `legacy/mcp_server.py`: legacy prototype local stdio MCP server; packaged builds use the app-bundled `scripts/cortex_mcp_stdio.py` proxy instead
+- `legacy/ui.py`: Streamlit memory chat
-## Productized MVP
+These modules are isolated under `legacy/`, retained for reference, and not
+used by the packaged app. New product work belongs in `backend/app/`, `macos/`,
+the SDKs, or the integration packages.
-The MVP adds a backend service and a native macOS client while preserving the extraction schema.
+## Current Product
+
+The current product adds a backend service and native macOS client while
+preserving the extraction schema.
```
macOS app
@@ -25,7 +72,9 @@ macOS app
Obsidian/local notes sync
|
v
-FastAPI backend
+Shared API surface
+ FastAPI: development and optional hosted plane
+ standalone_server.py: packaged local subset
/v1/captures
/v1/captures/queue
/v1/captures/{id}/status
@@ -98,7 +147,8 @@ Local vault
Rebuildable SQLite index
FTS5
sqlite-vec when available
- hash or opt-in OpenAI embeddings
+ bundled Model2Vec embeddings when available
+ deterministic hash fallback or opt-in OpenAI embeddings
normalized joins
Release pipeline
@@ -114,20 +164,49 @@ Release pipeline
downloadable release artifacts
```
-## Hosted Backend Migration
+## Request Lifecycle
+
+```mermaid
+sequenceDiagram
+ participant Source as Connected source
+ participant API as Local or hosted API
+ participant Review as Review policy
+ participant Store as Vault + index
+ participant Tool as AI tool / MCP client
+
+ Source->>API: Capture or sync records
+ API->>Review: Extract typed candidate memories
+ alt review required
+ Review-->>API: Pending until user approves
+ else trusted explicit import
+ Review-->>API: Auto-approved by policy
+ end
+ API->>Store: Persist source, memory, provenance, audit event
+ Tool->>API: Search / context / Ask with scoped token
+ API->>Store: Hybrid retrieval + policy filters
+ alt cited evidence is sufficient
+ API-->>Tool: Bounded context with memory IDs and citations
+ else evidence is missing
+ API-->>Tool: Explicit abstention
+ end
+```
+
+## Hosted Backend and Scale Path
-The local SQLite storage is intentionally swappable.
+The current hosted beta uses the same `CortexStore` behind `StoreRegistry`,
+which assigns isolated SQLite/vault shards per user. Postgres/pgvector is a
+future scale target, not the current hosted implementation.
-| Local Beta | Hosted Beta |
-|---|---|
-| User-owned local vault + SQLite index | FastAPI service with Postgres |
-| FTS5 keyword search | Postgres full-text search + pgvector |
-| local review status | hosted review workflow |
-| local install tokens | OAuth/login + scoped API tokens |
-| local vault files | durable object storage exports/backups plus relational memory rows |
-| localhost API and local MCP | HTTPS API and hosted MCP |
+| Local desktop | Current hosted beta | Future scale target |
+|---|---|---|
+| User-owned vault + SQLite index | Per-user vault/SQLite shards | Relational memory store + durable object storage |
+| FTS5 + sqlite-vec | FTS5 + sqlite-vec per shard | Postgres full-text + pgvector, if benchmarks justify it |
+| Loopback server + local MCP | FastAPI HTTPS + scoped account tokens | Same public contracts behind horizontally scaled services |
+| Local review state | User-isolated hosted review state | Durable queues and multi-region operations |
-Local beta remains SQLite/vault-first. For the 10k-user hosted path, FastAPI plus Postgres/pgvector is the default unless benchmarks prove a separate vector store is needed.
+Local mode remains vault/SQLite-first. The migration seam is the store registry
+and public API contract; a future database change should not alter connector,
+review, citation, or client behavior.
See `docs/MEMORY_BACKEND_BLUEPRINT.md` for the layered memory model and scale path across SQLite, sqlite-vec, libSQL/Turso, Postgres/pgvector, Qdrant, and LanceDB.
@@ -189,6 +268,7 @@ Each memory also has a retrieval layer:
- decision
- preference
- negative
+- procedural
### Entity
@@ -242,9 +322,14 @@ Default packaged app path:
~/Library/Application Support/Cortex/Cortex.vault/
```
-The vault contains human-readable JSON records for imports, source accounts, sync cursors, captures, memories, tasks, entities, and graph edges, plus `settings.json`, `events.jsonl`, attachments, exports, backups, and `index.sqlite`.
+The vault contains human-readable JSON records for imports, source accounts,
+sync cursors, captures, memories, tasks, entities, and graph edges. Memories
+also have editable Markdown notes; rebuild merges Markdown memories with the
+other durable JSON/JSONL records under the precedence rules in
+`LOCAL_VAULT_FORMAT.md`. SQLite is a rebuildable index, not the sole authority.
-`POST /v1/maintenance/rebuild-index-from-vault` clears the current user's index rows and rebuilds them from the vault records. This is the recovery path if the local index is corrupted or if a future sync process materializes records before rebuilding search.
+`POST /v1/maintenance/rebuild-index-from-vault` clears the current user's index
+rows and rebuilds them from that merged durable representation.
See `docs/LOCAL_VAULT_FORMAT.md`.
diff --git a/docs/BENCHMARKS.md b/docs/BENCHMARKS.md
new file mode 100644
index 00000000..638f6f47
--- /dev/null
+++ b/docs/BENCHMARKS.md
@@ -0,0 +1,97 @@
+# Cortex Benchmarks
+
+Cortex treats evaluation as executable product behavior, not a marketing
+number. The repository ships deterministic, offline harnesses for retrieval,
+context packing, adaptation, token calibration, routing, and end-to-end agent
+tasks. They run on synthetic fixtures with known answers so regressions fail in
+CI without sending personal data to an external model.
+
+## Recorded deterministic hash/FTS regression snapshot
+
+Run on 2026-07-30 with Python 3.12 on Apple Silicon. All commands used the
+repository's checked-in fixtures and made no model API calls.
+
+This checkout did not contain the packaged Model2Vec dependency and weights.
+The retrieval row below therefore measures the deterministic hash/FTS
+regression path, not semantic retrieval, hybrid lift, or the shipped model
+asset. The distinction is part of the result, not an implementation detail.
+
+Machine-readable provenance and limitations are committed in
+[`benchmarks/2026-07-30-local.json`](benchmarks/2026-07-30-local.json). This was
+one run from a dirty review tree based on commit `8eedd5c`; timing and controlled
+cache state were not captured, so it is reproducible input/result evidence, not
+a latency benchmark or a release artifact.
+
+| Gate | Cases / checks | Result | Selected metrics |
+|---|---:|---|---|
+| Deterministic hash/FTS retrieval | 163 labeled queries | Pass | top-1 accuracy 1.000; recall@3 1.000; precision@3 0.947 |
+| Model2Vec semantic/rerank lift | Not executed in this snapshot | Not measured | Release/model-provisioned environments must run the non-skipping command below |
+| Relevance monotonicity | 160 reordered cases / 41 pairs | Pass | pairwise concordance 1.000; reordered top-1 1.000 |
+| Context packing | 10 tasks / 30 checks | Pass | citation coverage 1.000; MRR 1.000; nDCG@k 0.888; no-leak 1.000; budget adherence 1.000 |
+| Session replay | 2 sessions / 9 turns | Pass | recall@3 1.000; prefetch hit rate 0.750; delta token savings 28.5%; no resend/leak 1.000 |
+| Adaptation | 7 memories / 10 derived rules | Pass | readiness score 100 |
+| Token calibration | Claude + Cursor profiles | Pass | mean profile MAPE 0.0158 vs flat-estimator MAPE 0.0743 |
+
+## Reproduce
+
+```bash
+make setup
+.venv/bin/python scripts/retrieval_eval.py
+.venv/bin/python scripts/context_pack_eval.py
+.venv/bin/python scripts/adaptation_eval.py
+.venv/bin/python scripts/token_calibration_eval.py
+```
+
+To verify the real semantic path, provision the local Model2Vec package and
+model asset, then require a non-skipping run:
+
+```bash
+CORTEX_EMBEDDING_PROVIDER=model2vec \
+ .venv/bin/python scripts/rerank_eval.py --forbid-skip
+```
+
+Without the package/model, `rerank_eval.py` reports `status: skipped`;
+`--forbid-skip` converts that missing evidence into a failing gate. Release
+validation also uses `scripts/check_vector_runtime.py --require-model2vec`.
+
+`make check` runs the fast pre-PR subset. The full CI workflow adds routing,
+adapter, delivery, agent-task, connector, docs, security, and packaging gates.
+
+## What these numbers mean
+
+- **Top-1 accuracy / recall** measure whether the known relevant synthetic
+ fixture is returned at the expected rank on the named retrieval path.
+- **Citation coverage** requires packed evidence to retain provenance.
+- **No-leak** fails when a deliberately disallowed cross-project memory appears.
+- **Budget adherence** checks the serialized context against its token budget.
+- **Session token savings** compares delta delivery with naively resending a
+ full context pack every turn.
+- **Calibration MAPE** measures the error between a profile's estimate and the
+ harness's simulated reference count. It is not validation against a live
+ vendor tokenizer.
+
+## Limitations
+
+These are deterministic regression benchmarks, not proof of real-world answer
+quality. The fixtures are synthetic, comparatively small, and shaped around
+known Cortex behaviors. The token-calibration harness uses simulated reference
+counts rather than live vendor tokenizers. Runtime varies by hardware and warm
+cache state, so this page does not claim a production latency percentile.
+
+The committed snapshot is also a one-run record from a dirty review tree. It
+does not include a complete dirty diff, raw per-case artifacts, repetitions,
+or controlled cache timings; it cannot reconstruct the exact executable state
+as a scientific artifact. Re-run the current checkout before relying on the
+numbers for engineering decisions.
+
+Before making broad quality claims, Cortex still needs:
+
+1. a versioned, consented corpus of real user tasks;
+2. blinded human relevance judgments;
+3. cold- and warm-cache latency percentiles on release hardware;
+4. scale curves across vault sizes and concurrent hosted tenants; and
+5. confidence intervals across repeated runs where nondeterminism is enabled.
+
+When results are published, record the commit, fixture version, hardware,
+configuration, repetitions, and raw output. Do not compare numbers produced by
+different harness versions as if they were the same experiment.
diff --git a/docs/CODE_MAP.md b/docs/CODE_MAP.md
new file mode 100644
index 00000000..71487cbb
--- /dev/null
+++ b/docs/CODE_MAP.md
@@ -0,0 +1,68 @@
+# Cortex Contributor Code Map
+
+Use this page to find the first file to open. Cortex has two HTTP runtimes around
+one storage/retrieval core, so runtime-facing changes usually need parity tests.
+
+| I want to change… | Start here | Verify with |
+|---|---|---|
+| FastAPI development/hosted route | [`backend/app/main.py`](../backend/app/main.py), [`backend/app/models.py`](../backend/app/models.py) | [`backend/tests/test_fastapi_contract.py`](../backend/tests/test_fastapi_contract.py) |
+| Packaged local HTTP behavior | [`backend/app/standalone_server.py`](../backend/app/standalone_server.py) | [`backend/tests/test_standalone_server.py`](../backend/tests/test_standalone_server.py) |
+| Capture extraction | [`backend/app/extractor.py`](../backend/app/extractor.py) and capture methods in [`storage.py`](../backend/app/storage.py) | [`test_extractor_quality.py`](../backend/tests/test_extractor_quality.py), [`test_storage_lifecycle.py`](../backend/tests/test_storage_lifecycle.py) |
+| Search/ranking | Retrieval section near `_search_rows` in [`backend/app/storage.py`](../backend/app/storage.py) | [`test_retrieval_quality.py`](../backend/tests/test_retrieval_quality.py), [`scripts/retrieval_eval.py`](../scripts/retrieval_eval.py) |
+| Context packs / CMP | Context-pack methods in [`backend/app/storage.py`](../backend/app/storage.py), [`backend/app/models.py`](../backend/app/models.py) | [`scripts/context_pack_eval.py`](../scripts/context_pack_eval.py) |
+| Embeddings/vector index | [`backend/app/embeddings.py`](../backend/app/embeddings.py), vector-index section in [`storage.py`](../backend/app/storage.py) | [`test_embeddings.py`](../backend/tests/test_embeddings.py), [`test_vector_dim_migration.py`](../backend/tests/test_vector_dim_migration.py) |
+| A source connector | [`backend/app/connectors/`](../backend/app/connectors), connector setup/scheduler methods in [`storage.py`](../backend/app/storage.py) | [`ADDING_A_CONNECTOR.md`](ADDING_A_CONNECTOR.md) |
+| MCP tool behavior | [`backend/app/mcp_tools.py`](../backend/app/mcp_tools.py) | [`backend/tests/`](../backend/tests) (`test_mcp_*.py`) |
+| Vault, backup, and repair | [`backend/app/vault.py`](../backend/app/vault.py), lifecycle/maintenance methods in [`storage.py`](../backend/app/storage.py) | [`backend/tests/`](../backend/tests) (`test_vault*.py`), [`test_native_vault_source_of_truth.py`](../backend/tests/test_native_vault_source_of_truth.py), [`test_job_reliability.py`](../backend/tests/test_job_reliability.py) |
+| Authentication and capabilities | [`backend/app/authn.py`](../backend/app/authn.py), [`backend/app/accounts.py`](../backend/app/accounts.py), route/tool policies in [`main.py`](../backend/app/main.py) and [`mcp_tools.py`](../backend/app/mcp_tools.py) | [`test_authn_sessions.py`](../backend/tests/test_authn_sessions.py), [`test_auth_http.py`](../backend/tests/test_auth_http.py), capability contract tests |
+| Python SDK | [`sdk/python/cortex_client/client.py`](../sdk/python/cortex_client/client.py) | [`sdk/python/tests/`](../sdk/python/tests) |
+| TypeScript SDK | [`sdk/typescript/src/index.ts`](../sdk/typescript/src/index.ts) | `npm test` in [`sdk/typescript/`](../sdk/typescript) |
+| macOS UI / lifecycle | [`macos/Sources/`](../macos/Sources) | `swift test` in [`macos/`](../macos) |
+| Public examples | [`examples/`](../examples) | `make examples-check` |
+| Release metadata/site | [`site/`](../site), [`macos/package_release.sh`](../macos/package_release.sh) | `make docs-check` |
+
+## Runtime Parity
+
+`backend/app/main.py` is the FastAPI development and optional hosted surface.
+`backend/app/standalone_server.py` is the dependency-light server bundled in
+the desktop app. Both call the same `CortexStore`, but they have separate HTTP
+adapters.
+
+When adding or changing an endpoint:
+
+1. update the shared model/store behavior;
+2. update both HTTP adapters when the endpoint belongs in local mode;
+3. add one FastAPI contract test and one standalone-server contract test;
+4. run `make runtime-check`.
+
+Hosted-only filesystem operations must stay hosted-safe: a path sent to FastAPI
+refers to the server filesystem, never the user's Mac.
+
+## About `storage.py`
+
+`backend/app/storage.py` is a large compatibility boundary that currently owns
+schema migrations and several legacy domain services. Avoid adding an unrelated
+new subsystem to it.
+
+- Put pure parsing, vendor HTTP, scoring, or transformation logic in a focused
+ module.
+- Keep `CortexStore` as the transaction/orchestration boundary.
+- Reuse public store methods instead of duplicating persistence rules in routes.
+- Add a narrow private helper when behavior must remain transaction-local.
+- Preserve vault-first durability: SQLite is a rebuildable index, not the sole
+ source of truth.
+
+This is an architectural direction, not a claim that the existing module has
+already been split.
+
+## Fast Validation Loop
+
+```bash
+make doctor
+make check
+make runtime-check
+```
+
+Run `make test` before a broad backend pull request. SDK-only changes can use
+their focused test suites first, but `make check` remains the required local
+pre-PR gate.
diff --git a/docs/CONNECTOR_COVERAGE_READINESS.md b/docs/CONNECTOR_COVERAGE_READINESS.md
index ed49d624..57ed2e9c 100644
--- a/docs/CONNECTOR_COVERAGE_READINESS.md
+++ b/docs/CONNECTOR_COVERAGE_READINESS.md
@@ -1,6 +1,15 @@
# Connector Coverage Readiness
-This map is the product-facing source coverage plan for the first-100-user beta and the later live OAuth phase.
+This map is the product-facing source coverage plan for the first-100-user beta
+and later managed-OAuth rollout.
+
+> **Implementation status (audited 2026-07-30):** Thirteen read-only connector
+> modules are wired into the local backend. GitHub device-flow sign-in is
+> configured. Managed Google, Microsoft, and Notion OAuth flows are implemented,
+> but the direct build ships those client IDs empty, so token/key setup remains
+> the available path until release configuration supplies them. The executable
+> setup contract in `backend/app/storage.py` overrides older roadmap wording in
+> this document.
## Phase Definitions
@@ -8,7 +17,12 @@ First-100 beta means local-first connected-source setup first. A supported servi
Advanced/Fallback import remains only for unsupported services, migrations, legal exports, and support recovery. It is not the primary product loop and should not be presented as the normal setup path.
-Live OAuth means direct cloud sync for services that expose appropriate APIs. Cortex now has the local source-account sync ingestion contract, but source-specific OAuth/sign-in flows still need explicit user consent, least-privilege scopes, durable source account health, reconnect states, sync cursors, rate-limit handling, deletion semantics, and product copy that distinguishes account sync from fallback import.
+Live OAuth means direct cloud sync for services that expose appropriate APIs.
+The source-account sync contract, GitHub device flow, and managed
+Google/Microsoft/Notion OAuth flows are implemented. A release still needs to
+provide the latter three providers' client IDs and validate consent, reconnect,
+rate-limit, deletion, and source-health behavior before presenting them as
+configured sign-in options.
## Readiness Legend
@@ -42,10 +56,10 @@ Representative catalog expectations:
| ChatGPT | `export-only` | MCP/direct AI-tool bridge where available; fallback export stays Advanced/Fallback. | None. |
| Claude | `export-only` | MCP/direct AI-tool bridge where available; fallback export stays Advanced/Fallback. | None. |
| Apple Mail | `import-ready` | Planned permissioned local mail integration; fallback `.eml`, `.emlx`, or `.mbox` stays Advanced/Fallback. | None. |
-| Gmail | `token-ready` | Backend read-only Gmail sync works when a trusted OAuth access token is already available; managed Google sign-in remains planned. | `gmail.readonly`. |
+| Gmail | `token-ready` | Read-only sync and managed Google OAuth are implemented; the current direct build has no Google client ID, so token setup is the available path. | `gmail.readonly`. |
| Notion | `token-ready` | Read-only page sync works with an internal integration token shared into selected pages; fallback Markdown/CSV/HTML export stays Advanced/Fallback. | `read_content`. |
| Slack | `token-ready` | Read-only selected channel sync works with a bot or user token; fallback workspace export stays Advanced/Fallback. | `channels:history`, `groups:history`, `channels:read`, `groups:read`. |
-| GitHub | `token-ready` | Read-only GitHub issue and pull request sync works through source-account sync with a user-supplied token; fallback issue, PR, project, CSV, JSON, Markdown, or text export stays Advanced/Fallback. | `repo:read`. |
+| GitHub | `token-ready` | Read-only issue and pull-request sync supports configured GitHub device flow, with a pasted token as fallback. | `repo:read`. |
| Linear | `token-ready` | Read-only issue sync works with a personal API key. | `read`. |
| Jira | `token-ready` | Read-only issue sync works with a Jira Cloud site URL, Atlassian account email, and API token. | `read:jira-work`. |
| Readwise | `token-ready` | Read-only highlight sync works with a user access token. | `read`. |
@@ -73,10 +87,10 @@ First-100 launch copy should still treat MCP AI tools and Obsidian/local notes a
| Connector | Functional now | Local-only / local-first boundary | Explicitly not promised |
| --- | --- | --- | --- |
| Obsidian | Scans a user-selected Markdown/text vault, registers a source account, syncs records through Review, preserves file citations, advances cursors, skips unchanged notes, and supersedes edited notes. | Local folder access only after the user selects the vault. This is the first native first-100 connector. | No Obsidian cloud account sync, no remote vault crawl, and no write-back to notes. |
-| GitHub | Syncs read-only issues and pull requests from selected repositories with stable GitHub citations and cursor-backed source-account state. | User-supplied read token at sync time; local backend stores source-account/cursor metadata, not a managed OAuth app flow. | No GitHub OAuth install, no writes/comments, no project/discussion coverage promise, and no org-wide discovery promise. |
-| Gmail | Syncs read-only messages from Gmail using an explicit access token, query/label filters, pagination, parsed message bodies, and stable message citations. | The backend can consume an access token supplied by a local connector or operator path; managed Google sign-in is not shipped. | No managed Gmail OAuth, no mailbox writes, no broad label policy UI, and no authorship claims without identity aliases. |
-| Google Drive | Syncs read-only Google Drive files and exported Google Docs/text/HTML content with stable file citations, pagination, and cursor state. | The backend can consume an access token supplied by a local connector or operator path; unsupported binaries are skipped. | No managed Google OAuth, no Drive writes, no full binary/PDF OCR promise, and no Docs revision UI. |
-| Outlook | Syncs read-only Outlook/Microsoft Graph mail messages with parsed bodies, pagination, and stable message citations. | The backend can consume an access token supplied by a local connector or operator path; mail is the current wired slice. | No managed Microsoft OAuth, no Outlook writes, no Teams/OneDrive/contacts coverage in this connector, and no broad tenant administration. |
+| GitHub | Syncs read-only issues and pull requests from selected repositories with stable GitHub citations and cursor-backed source-account state. | GitHub device-flow sign-in is configured; a pasted token remains the advanced fallback. | No GitHub App install, no writes/comments, no project/discussion coverage promise, and no org-wide discovery promise. |
+| Gmail | Syncs read-only messages from Gmail using an explicit access token, query/label filters, pagination, parsed message bodies, and stable message citations. | Managed Google OAuth is implemented but the direct build ships no Google client ID; token setup remains available. | No mailbox writes, no broad label policy UI, and no authorship claims without identity aliases. |
+| Google Drive | Syncs read-only Google Drive files and exported Google Docs/text/HTML content with stable file citations, pagination, and cursor state. | Managed Google OAuth is implemented but the direct build ships no Google client ID; unsupported binaries are skipped. | No Drive writes, no full binary/PDF OCR promise, and no Docs revision UI. |
+| Outlook | Syncs read-only Outlook/Microsoft Graph mail messages with parsed bodies, pagination, and stable message citations. | Managed Microsoft OAuth is implemented but the direct build ships no Microsoft client ID; mail is the current wired slice. | No Outlook writes, no Teams/OneDrive/contacts coverage in this connector, and no broad tenant administration. |
| Slack | Syncs read-only messages from selected channels with Slack permalinks or stable fallback citations and per-channel cursor state. | User-supplied bot/user token and explicit channel list. | No managed Slack OAuth, no broad workspace crawl, no DM/private-channel promise beyond granted scopes, and no automatic user-authorship attribution without aliases. |
| Readwise | Syncs read-only highlights with pagination, source URLs or stable fallback citations, and source-account cursors. | User-supplied Readwise access token. | No OAuth, no write/highlight management, and no guarantee that unsupported Readwise object types sync. |
| Calendar | Syncs read-only local `.ics` files or user-provided `.ics` feeds into event records with generated safe citations. | Local file/feed only; private feed URLs are not a product surface. | No Google Calendar/Microsoft OAuth, no system calendar database access, no calendar writes, and no background calendar daemon. |
@@ -84,9 +98,13 @@ First-100 launch copy should still treat MCP AI tools and Obsidian/local notes a
| Zotero | Syncs read-only items, notes, and annotations through the local desktop API by default, with optional Web API token support and Zotero item citations. | Local API is the default; attachment import is off by default. | No Zotero OAuth, no library writes, and no attachment/PDF content import promise by default. |
| Linear | Syncs read-only issues with pagination, source URLs or `linear://` fallback citations, and source-account cursors. | User-supplied Linear API key. | No OAuth, no issue writes/comments, no project/team administration, and no workflow mutation. |
| Jira | Syncs read-only Jira Cloud issues with a site URL, account email, API token, optional JQL, stable browse URLs, and cursor state. | User-supplied Atlassian API token; Jira Cloud path only. | No OAuth, no issue writes/transitions, no full project/changelog coverage promise, and no on-prem Jira guarantee. |
-| Notion | Syncs read-only pages shared with a Notion internal integration token, preserving page IDs, page URLs, pagination, and optional page content. | User-supplied integration token; only pages shared with the integration are in scope. | No consumer OAuth, no whole-workspace discovery, no writes, and no full block/database fidelity promise. |
+| Notion | Syncs read-only pages shared with a Notion integration, preserving page IDs, page URLs, pagination, and optional page content. | Managed Notion OAuth is implemented but the direct build ships no Notion client ID; an integration token remains available. | No whole-workspace discovery beyond granted pages, no writes, and no full block/database fidelity promise. |
-Across all thirteen, the current promise is read-only local source-account sync into Review and cited Ask. The checklist explicitly does not promise managed OAuth, secret custody, hosted background workers, hosted deletion/export receipts, team administration, two-way service writes, billing/quotas, or primary first-run UI placement for every connector.
+Across all thirteen, the current promise is read-only local source-account sync
+into Review and cited Ask. The checklist does not promise that unconfigured
+managed OAuth providers work in a distributed build, nor hosted background
+workers, team administration, two-way service writes, billing/quotas, or
+primary first-run UI placement for every connector.
## Coverage Map
@@ -94,11 +112,11 @@ Across all thirteen, the current promise is read-only local source-account sync
| --- | --- | --- | --- | --- |
| ChatGPT / Claude / MCP tools | Connect local AI tools through MCP so assistants can read approved memory and write source records into Review. | Beta ready through MCP bridge. | Direct account import can stay planned; MCP output is the first useful integration surface. | `chatgpt`, `claude`, `mcp`, and canonical AI-tool sources. |
| Obsidian/Markdown | In-app Obsidian vault connector for Markdown/text notes, with review-first source-account sync. | Beta ready for explicit local vault connection. | Local folder connector first; a background watcher can build on the same cursor contract later. | `obsidian`, `knowledge-base`, and `docs`. |
-| Gmail | Advanced read-only Gmail connector backed by source-account sync with an explicit access token. | Backend wired; managed OAuth planned; export remains Advanced/Fallback for unsupported setup. | OAuth planned with read-only mail scopes, incremental cursors, reconnect, and label/thread preservation. | `gmail` maps to canonical `email`. |
+| Gmail | Advanced read-only Gmail connector backed by source-account sync with an explicit access token. | Backend and managed OAuth flow are wired; the direct build's Google client ID is empty. | Configure and validate the shipped Google OAuth client before making managed sign-in a release promise. | `gmail` maps to canonical `email`. |
| Apple Mail | Planned permissioned local mail connector; fallback export stays Advanced/Fallback. | Beta conditional. | Local/import only unless a safe permissioned local integration is added. | `apple-mail` maps to canonical `email`. |
-| Outlook | Advanced read-only Outlook mail connector backed by source-account sync with an explicit Microsoft Graph access token. | Backend wired for mail; managed OAuth planned; export remains Advanced/Fallback for unsupported setup. | OAuth planned through Microsoft Graph for broader mail, calendar, contacts, and files. | `outlook` maps to `email`, `calendar`, `contacts`, and `cloud-docs`. |
-| Notion | Advanced Notion integration-token connector backed by source-account sync for pages shared with the integration. | Token-ready; primary OAuth is still planned. | OAuth later for consumer-grade sign-in. Preserve page/database IDs and cite page URLs. | `notion`. |
-| Google Drive/Docs | Advanced read-only Drive connector backed by source-account sync with an explicit access token; Google Docs export is supported through Drive. | Backend wired for Drive files and exported Docs/text/HTML; managed OAuth planned; standalone Docs OAuth remains planned. | OAuth planned through Drive/Docs read-only scopes with file cursors and revision safety. | `google-drive` and `google-docs` map to `cloud-docs` and `docs`. |
+| Outlook | Advanced read-only Outlook mail connector backed by source-account sync with an explicit Microsoft Graph access token. | Backend and managed OAuth flow are wired for mail; the direct build's Microsoft client ID is empty. | Configure the shipped OAuth client before broadening coverage to calendar, contacts, or files. | `outlook` maps to `email`, `calendar`, `contacts`, and `cloud-docs`. |
+| Notion | Advanced Notion integration-token connector backed by source-account sync for pages shared with the integration. | Backend and managed OAuth flow are wired; the direct build's Notion client ID is empty. | Configure and validate the shipped Notion OAuth client before making one-click sign-in a release promise. | `notion`. |
+| Google Drive/Docs | Advanced read-only Drive connector backed by source-account sync with an explicit access token; Google Docs export is supported through Drive. | Backend and managed OAuth flow are wired; the direct build's Google client ID is empty. | Configure and validate the shipped Google OAuth client; preserve file cursors and revision safety. | `google-drive` and `google-docs` map to `cloud-docs` and `docs`. |
| Slack | Advanced read-only channel connector backed by source-account sync with a bot/user token and selected channels. | Token-ready; primary OAuth is still planned. | OAuth later for channels, private channels, and DMs where granted. Requires workspace policy clarity. | `slack`. |
| Discord | Planned only if a user-consented history API or connector path becomes product-safe. | Local/import only. | Local/import only until an official user-consented history export or API path is product-safe. | `discord`. |
| Calendar | Read-only local `.ics` file/feed connector backed by source-account sync; Google/Microsoft account sign-in remains later. | Local/feed-ready; OAuth planned. | Preserve event UIDs and use generated source-account citations so local paths and private feed URLs do not leak. | `calendar`. |
diff --git a/docs/DISTRIBUTION.md b/docs/DISTRIBUTION.md
index ca12db8e..b77102ba 100644
--- a/docs/DISTRIBUTION.md
+++ b/docs/DISTRIBUTION.md
@@ -1,9 +1,12 @@
# Cortex Landing Page and Distribution
-This document defines the controlled distribution path for the local-first macOS
-beta. For the first 100 testers, the current path is an unnotarized direct
-local-beta DMG unless the exact build has completed Developer ID signing,
-notarization, and stapling.
+This document defines the controlled distribution paths for the local-first
+macOS beta. The current download referenced by
+`site/downloads/latest.json` is a Developer ID signed, Apple-notarized direct
+DMG hosted in `doppl-tech/releases`. An ad-hoc-signed `local-beta` path remains
+available for internal QA, but it is not the build described by the public
+download page. The direct build requires account sign-in; local retrieval
+continues to run on-device and cloud capture sync is optional.
## Goal
@@ -16,27 +19,33 @@ Ship a clean beta experience:
5. User completes the local setup loop.
6. User can verify privacy, checksums, and update metadata.
-## First-100 Distribution Status
+## Current Distribution Status
-Current first-100 local-beta distribution is ready for controlled named-tester
-invites when the package, static site, clean-profile install, update, rollback,
-and support-bundle checks pass for the exact build being shared.
+The current direct beta is ready for controlled tester distribution when the
+package, static site, clean-profile install, update, rollback, and
+support-bundle checks pass for the exact build being shared.
-It is not a Gatekeeper-ready public release by default:
+For the build named by `site/downloads/latest.json`:
-- The local-beta app is ad-hoc signed, not Developer ID signed.
-- The local-beta app and DMG are not notarized or stapled.
-- The user may need Control-click > Open on first launch.
-- Updates are manual DMG downloads and app replacement.
+- The app is Developer ID signed and notarized by Apple.
+- The normal install path is a double-click launch after copying the app to
+ Applications.
+- Cortex checks the HTTPS update feed and directs users to the current DMG.
+- Installation and rollback still use app replacement; there is no background
+ self-updater.
- Rollback is manual app replacement with the local memory folder left in place.
-- The site hosts downloads and metadata; it does not provide automatic update
- installation.
+- GitHub Releases hosts the binaries; the site hosts release metadata,
+ checksums, and download links.
-Do not invite first-100 users from a generic public download page unless the page
-clearly says this is a local-first beta, lists the manual install/update path,
-links to privacy and checksum information, and avoids promises about
-notarization, automatic updates, cloud backup, hosted accounts, or production
-support.
+This signing state does not make Cortex a broad production service. Public copy
+must still identify it as a local-first beta, describe the app-replacement
+update path, link privacy and checksum information, and avoid promises about
+background updates, cloud backup, production-grade hosted operations, or
+production support.
+
+Internal ad-hoc builds must use a separate handoff that explicitly documents
+Control-click > Open. Never use that internal signing language for the current
+notarized public DMG.
## Static Site
@@ -59,7 +68,7 @@ The hero uses a canvas memory map instead of stock photography. This keeps the p
## Prepare Downloads
-After packaging the app:
+For an internal ad-hoc QA build:
```bash
./macos/package_release.sh \
@@ -88,8 +97,8 @@ python3 scripts/prepare_distribution_site.py \
This copies the DMG, ZIP, checksums, and update feed into `site/downloads/`.
-This can host the first-100 local-beta artifacts, but hosting over HTTPS does
-not make the build notarized or automatic-update ready.
+This path can host internal QA artifacts, but HTTPS hosting does not change
+their signing state or make them automatic-update ready.
For a Gatekeeper-ready public direct download release, use the fail-closed
packaging path instead:
@@ -209,14 +218,16 @@ scripts/check_distribution_site.py --self-test` to exercise both branches.
## GitHub Releases Distribution (free binary hosting in the org)
-Committing the DMG/ZIP into `site/downloads/` bloats git (~37 MB of stale
-binaries today). The public path is to attach the notarized binaries to a GitHub
-Release in the canonical org repo (free hosting) and point `latest.json` at the
-Release asset URLs, so the site links to the Release and git stays small.
+Committing the DMG/ZIP into `site/downloads/` bloats git. The current public
+path attaches notarized binaries to
+[`doppl-tech/releases`](https://github.com/doppl-tech/releases/releases) and
+points `latest.json` at the Release asset URLs, so the site links to the Release
+and git stays small.
-The canonical org repo is decided by DECISION 0 in
-`docs/REMAINING_LAUNCH_WORK.txt` (recommended: an org repo such as
-`doppl-tech/cortex`). Pass it as `--repo OWNER/REPO`.
+The canonical source repository is
+[`trace-cortex/cortex-app`](https://github.com/trace-cortex/cortex-app); binary
+artifacts currently use the separate `doppl-tech/releases` repository. Pass the
+binary repository as `--repo OWNER/REPO`.
Procedure:
@@ -228,7 +239,7 @@ Procedure:
CORTEX_NOTARY_PROFILE=cortex-notary \
CORTEX_BUNDLE_PYTHON=1 \
./macos/package_release.sh --production --channel stable \
- --base-url https://github.com/doppl-tech/cortex/releases/download/v0.1.0-1
+ --base-url https://github.com/doppl-tech/releases/releases/download/v0.2.0-52
```
The `--base-url` can point at the Release download prefix so the release
@@ -246,9 +257,9 @@ Procedure:
```bash
scripts/publish_release.sh \
- --tag v0.1.0-1 \
- --release-dir outputs/Cortex-0.1.0-1 \
- --repo doppl-tech/cortex
+ --tag v0.2.0-52 \
+ --release-dir outputs/Cortex-0.2.0-52 \
+ --repo doppl-tech/releases
```
`scripts/publish_release.sh`:
@@ -286,30 +297,23 @@ assets. This is founder task F4d. In a GitHub Actions workflow, grant
`permissions: contents: write` to the job and export the token as `GH_TOKEN` so
`gh` picks it up.
-### Removing the binaries from git (after the first Release)
+### Keeping binaries out of git
-The stale binaries in `site/downloads/` are intentionally **left in place for
-now**: the current `latest.json` still references local files, and removing them
-before a real Release exists would break the download feed. The first notarized
-Release has not been published yet (it needs F2/F10 — the Developer ID cert and a
-notarized DMG).
+The first notarized releases have been published and the current
+`site/downloads/latest.json` uses GitHub Release URLs. Large DMG and ZIP
+artifacts therefore stay out of git.
-Once the first notarized Release is published and `latest.json` points at the
-Release URLs (step 3 above), remove the binaries from git — the checker now
-tolerates absent, Release-hosted artifacts:
+For future releases, keep the binaries ignored and verify that the manifest
+points at the newly published Release:
```bash
-git rm --cached site/downloads/*.dmg site/downloads/*.app.zip
-# add site/downloads/*.dmg and site/downloads/*.app.zip to .gitignore
python3 scripts/check_distribution_site.py # still green: URLs are Release-hosted
```
Keep `site/downloads/latest.json`, `distribution.json`, and the
-`*.checksums.txt` in git. Also update `site/index.html`'s download links to point
-at the Release URLs (or at `latest.json`), since the checker validates local HTML
-references and those download buttons currently target the local
-`downloads/Cortex--.dmg` files. (This repo's site HTML is owned separately;
-flag it as part of the same change.)
+`*.checksums.txt` in git. Update `site/index.html` and the manifest together so
+the visible buttons, checksums, and machine-readable feed identify the same
+build.
## Manual Site QA
@@ -473,20 +477,23 @@ Positioning:
Cortex is not just AI memory. It is your personal operating model for the AI tools you already use.
```
-## Public Distribution Blockers
+## Broad-Launch Status
-Before broad public distribution:
+Completed for the current direct macOS beta:
- Apple Developer ID Application certificate
- hardened runtime signing
- notarized app and DMG
- stapled notarization tickets
- HTTPS-hosted downloads
+- rollback artifacts for the current release
+
+Remaining before a broad production launch:
+
- signed update feed or Sparkle appcast
- formal privacy policy review
- crash/error reporting decision
-- support email or feedback form
-- rollback plan for broken releases
+- formal support and incident-response ownership
The local static site is enough for a small free beta. The public site should not promise automatic updates, hosted sync, team accounts, or background capture until those systems exist.
@@ -495,12 +502,12 @@ The local static site is enough for a small free beta. The public site should no
Do not describe these as available beta capabilities:
- automatic app updates or in-app rollback
-- hosted accounts, cloud sync, or cloud backup
-- live OAuth/API sync for third-party services
+- cloud backup or production-grade hosted operations
+- preconfigured managed Google, Microsoft, or Notion OAuth in the distributed
+ build (their client IDs currently ship empty)
- remote MCP/OAuth
- billing, teams, enterprise policy, or hosted analytics
- production incident response or telemetry
-- notarized external distribution unless the current build completed Developer ID signing and notarization
## Free Beta Checklist
@@ -508,9 +515,10 @@ The beta is ready to share with a small group when:
- generated artifacts pass checksum, manifest, and package-readiness verification
- the exact hosted DMG matches the verified release directory
-- current copy states the build is an unnotarized local beta unless the exact
- build completed Developer ID signing and notarization
-- the Control-click > Open path has been tested for the unnotarized build
+- public copy matches the manifest's version, build, signing state, and artifact
+ URLs
+- normal double-click launch is tested for the notarized public DMG; the
+ Control-click > Open path is tested only for explicitly internal ad-hoc builds
- a user can download and install in under two minutes
- the app opens from Applications
- first-run setup completes without docs
diff --git a/docs/EXTERNAL_INTEGRATIONS.md b/docs/EXTERNAL_INTEGRATIONS.md
index 2685ebcb..a02df399 100644
--- a/docs/EXTERNAL_INTEGRATIONS.md
+++ b/docs/EXTERNAL_INTEGRATIONS.md
@@ -4,10 +4,20 @@ Cortex is local-first: it runs a small server on your Mac at `http://127.0.0.1:8
integration talks to *that* — your memory never leaves your machine unless you explicitly send it.
This doc covers every way an external app can use your Cortex memory as context.
-All requests authenticate with a scoped token in `Authorization: Bearer `. Tokens are minted
-in the Cortex app (or `POST /v1/pair` / `POST /v1/integrations/mcp-token`) and carry scopes
-(`read`, `write`, `export`, `maintenance`, `destructive`). **Advertisement is never authorization** —
-every call is re-checked against the token's scopes and your Connections & Privacy trust toggles.
+Cortex has two scoped token audiences, both sent as
+`Authorization: Bearer `:
+
+- `cxa_` tokens authenticate the REST API, including `/v1/context`,
+ `/v1/search`, `/v1/ask`, and `/v1/tools/*`. Use these with the Python and
+ TypeScript SDKs.
+- `cxm_` tokens authenticate only the MCP JSON-RPC endpoint at `/mcp` and its
+ stdio bridge. Do not put an MCP token in an SDK.
+
+Tokens are minted in the Cortex app (or the matching integration-token
+endpoint) and carry scopes (`read`, `write`, `export`, `maintenance`,
+`destructive`). **Advertisement is never authorization** — every call is
+re-checked against the token's scopes and your Connections & Privacy trust
+toggles.
## 1. MCP (Claude Desktop, Claude Code, Cursor, VS Code)
@@ -61,7 +71,7 @@ MCP:
Example (OpenAI Python):
```python
import openai, requests
-BASE, TOK = "http://127.0.0.1:8766", ""
+BASE, TOK = "http://127.0.0.1:8766", ""
tools = requests.get(f"{BASE}/v1/tools/schema?format=openai", headers={"Authorization": f"Bearer {TOK}"}).json()["schema"]
resp = openai.chat.completions.create(model="...", messages=[...], tools=tools)
# For each tool call, POST /v1/tools/call {name, arguments} and feed the result back.
@@ -73,7 +83,7 @@ resp = openai.chat.completions.create(model="...", messages=[...], tools=tools)
(`@doppl-tech/cortex-client`, fetch-based) wrap the above:
```python
from cortex_client import CortexClient
-cx = CortexClient(token="") # base_url defaults to loopback
+cx = CortexClient(token="") # base_url defaults to loopback
pack = cx.context("prep for the Acme sync") # cited context pack
answer = cx.ask("what did we decide about pricing?") # cite-or-abstain
tools = cx.openai_tools() # ready for tools=[...]
diff --git a/docs/FAQ.md b/docs/FAQ.md
new file mode 100644
index 00000000..6f121cdf
--- /dev/null
+++ b/docs/FAQ.md
@@ -0,0 +1,77 @@
+# Cortex FAQ
+
+## Is Cortex local?
+
+The vault, SQLite index, embeddings, retrieval, Ask, and profile computation run
+on the Mac. The direct beta requires account sign-in, and optional sync sends
+content to the hosted account plane. Client-side zero-access encryption exists
+but is opt-in and off by default.
+
+## Does Cortex call an LLM to answer questions?
+
+No generative model is bundled or called to write Ask responses. Ask composes
+from retrieved excerpts and either returns citations or abstains. An optional
+Anthropic key can be used for extraction in backend development, and an
+optional OpenAI key can be used for embeddings, but neither is required for the
+packaged local retrieval path.
+
+## Where is memory stored?
+
+The direct app uses:
+
+```text
+~/Library/Application Support/Cortex/Cortex.vault/
+```
+
+The Markdown/JSON records are authoritative. SQLite, FTS5, and vector indexes
+are rebuildable.
+
+## Why did Ask return no answer?
+
+That is usually the cite-or-abstain gate working. Approve relevant items in
+Review, verify the source synced successfully, and ask a more specific question.
+Do not weaken the gate merely to force an answer.
+
+## Which Macs are supported?
+
+The current Swift target is Apple Silicon on macOS 13 or later. There is no
+Intel/universal build.
+
+## Why does the app require sign-in if retrieval is local?
+
+The current direct build uses the account for identity and optional capture
+sync. Local retrieval and the user-owned vault stay on the Mac.
+
+## Does managed Notion, Google, or Microsoft sign-in work?
+
+The OAuth flows are implemented, but the current direct build ships their
+client IDs empty. Use an export or the supported pasted-token path until a
+release explicitly configures the provider.
+
+## Can Cortex import PDFs?
+
+The parser supports PDFs when `pypdf` is installed. It is not bundled in the
+current app, so PDF text extraction is not an advertised shipped capability.
+
+## Which AI tools can connect?
+
+Cortex can configure MCP access for Claude Desktop, Cursor, Windsurf, Zed,
+Cline, Roo Code, VS Code Copilot, and Claude Code. Other tools can use the local
+HTTP API or the Python/TypeScript SDK source packages.
+
+## Are the SDKs published?
+
+The SDKs are present and tested in this repository. Treat editable local
+installation as the supported contributor path until the package guides say a
+registry release is available.
+
+## What is pairwise digital-twin evaluation?
+
+It is an experimental evaluation mode that compares two candidate responses
+against cited evidence about the user. It lives on
+`feat/pairwise-twin-eval` and is not part of the current `main` release.
+
+## How do I report a bug or security problem?
+
+Use [SUPPORT.md](../SUPPORT.md) for normal issues and
+[SECURITY.md](../SECURITY.md) for private vulnerability reports.
diff --git a/docs/INSTALLER_AND_UPDATES.md b/docs/INSTALLER_AND_UPDATES.md
index 150b02a6..21de3fde 100644
--- a/docs/INSTALLER_AND_UPDATES.md
+++ b/docs/INSTALLER_AND_UPDATES.md
@@ -1,38 +1,42 @@
# Cortex Installer and Updates
-Cortex is currently a local-first macOS beta. The release system should make it easy to create a repeatable app package today while leaving a clean path to signed, notarized, automatic updates later.
+Cortex is currently a local-first macOS beta. The current direct-download build
+referenced by `site/downloads/latest.json` is Developer ID signed and notarized
+by Apple. It requires account sign-in, while memory retrieval remains local and
+cloud capture sync is optional. The release system also retains an ad-hoc
+`local-beta` track for internal QA.
-The first-100 release track is explicitly the unnotarized `local-beta`
-track unless a specific build completes the Apple Developer ID flow in
-`docs/APPLE_RELEASE.md`. Treat it as a controlled direct beta for named testers,
-not as a public macOS distribution.
+Signing and notarization are complete for the current public beta artifact.
+Cortex checks the release feed for newer builds, but installing or rolling back
+still uses normal app replacement rather than a background self-updater.
-## Current First-100 Track
+## Current Direct Beta Track
-Use the current local-beta path for the first 100 testers only when the invite
-copy, operator handoff, and support runbook all say the same thing:
+Use the direct beta path only when the landing page, release manifest, operator
+handoff, and support runbook all describe the exact artifact consistently:
- The app is packaged as a DMG and ZIP by `macos/package_release.sh`.
-- The app is ad-hoc signed for local verification, not Developer ID signed.
-- The app and DMG are not notarized or stapled.
-- macOS Gatekeeper may block first launch until the user uses Control-click >
- Open.
-- Updates and rollback are manual app replacement flows.
+- Production direct builds are Developer ID signed and notarized by Apple.
+- GitHub Releases hosts the binaries referenced by the HTTPS update feed.
+- Cortex can detect a newer build; installation and rollback remain app
+ replacement flows.
- The local memory folder is outside `Cortex.app` and must not be deleted during
install, update, or rollback.
-- The static site and `latest.json` are release metadata and download plumbing,
- not automatic update infrastructure.
+- The static site and `latest.json` provide release metadata and download
+ discovery, not automatic background installation.
-If any operator, invite, landing page, or handoff copy describes the current
-local-beta build as notarized, auto-updating, production-ready, or broadly
-public, the build is a no-go for first-100 invites.
+If any operator, invite, landing page, or handoff copy disagrees with the
+manifest about signing, notarization, version, build, or artifact URLs, the
+release is a no-go. An internal ad-hoc build must explicitly document the
+Control-click > Open path and must never reuse the public notarized-build copy.
## Ready And Not Ready
-Ready for the first-100 local-beta track:
+Ready for the current direct beta track:
- repeatable DMG, ZIP, checksum file, `latest.json`, and `BETA_HANDOFF.md`
generation
+- Developer ID signing, Apple notarization, and GitHub Release hosting
- checksum and manifest validation for generated artifacts
- manual install from DMG on macOS 13 or newer
- manual update by replacing `Cortex.app`
@@ -42,15 +46,12 @@ Ready for the first-100 local-beta track:
- live packaged-app smoke testing after launch
- content-free support bundle generation
-Not ready for the current first-100 local-beta track:
+Not included in the current direct beta:
-- Gatekeeper-ready public distribution
-- Developer ID notarization or stapling, unless the exact build completed
- `docs/APPLE_RELEASE.md`
- automatic background updates or in-app rollback
- Sparkle appcast or signed update-feed rollout
-- hosted accounts, cloud sync, cloud backup, billing, teams, or production
- telemetry
+- cloud backup, billing, teams, production telemetry, or production-grade
+ hosted operations
- production incident response for broad external launch
## Current Release Artifacts
@@ -147,8 +148,8 @@ will receive.
- Invite copy says the build is an unnotarized local beta when Developer ID
notarization has not completed.
- `BETA_HANDOFF.md`, landing-page copy, and support copy do not promise
- automatic updates, hosted accounts, cloud backup, broad OAuth sync, or
- production support.
+ automatic background updates, cloud backup, preconfigured broad OAuth
+ sync, or production support.
2. Generate or verify the package:
```bash
@@ -310,22 +311,26 @@ manual QA.
## Why Manual Updates First
-Manual updates are acceptable for early local beta because:
+Manual updates are acceptable for the current beta because:
-- there is no hosted account service yet
-- the app is ad-hoc signed in local builds
-- automatic updates require a signing/notarization/key-management decision
+- the shipped account service does not need control of the app bundle
+- internal QA builds may still be ad-hoc signed
+- background installation requires a dedicated update framework and separate
+ update-signing policy
- users must retain confidence that their local memory folder is not touched by app replacement
## Production Upgrade Path
-Before public distribution:
+Completed for the current direct beta:
- create an Apple Developer ID Application certificate
- sign the app with hardened runtime
- notarize the app and DMG
- staple notarization tickets
- host `latest.json` and artifacts over HTTPS
+
+Remaining before automatic background updates:
+
- rotate update feed keys separately from backend API keys
- add release rollback policy
- decide whether to adopt Sparkle for automatic updates
@@ -335,7 +340,7 @@ Sparkle is the likely production path for background update download/install. Th
## Release Checklist
- Increment `CFBundleShortVersionString` or `CFBundleVersion`.
-- Run `python3 -m unittest discover backend/tests`.
+- Run `python3 -m pytest backend/tests -q`.
- Run `python3 scripts/retrieval_eval.py`.
- Run `python3 scripts/adaptation_eval.py`.
- Run `./macos/build.sh`.
@@ -376,12 +381,12 @@ or newer user profile:
This system does not yet:
-- notarize the app
- install privileged helpers
- perform automatic replacement of the running app
- run delta updates
- verify update signatures beyond SHA-256 in the feed
- provide rollback from inside the app
-- provide hosted accounts, cloud backup, live OAuth/API sync, remote MCP/OAuth, billing, teams, or production telemetry
+- provide cloud backup, preconfigured managed Google/Microsoft/Notion OAuth,
+ remote MCP/OAuth, billing, teams, or production telemetry
Those are appropriate for the public-beta release track, not the local-first beta package.
diff --git a/docs/LOCAL_VAULT_FORMAT.md b/docs/LOCAL_VAULT_FORMAT.md
index 3ef0df9f..2a31effb 100644
--- a/docs/LOCAL_VAULT_FORMAT.md
+++ b/docs/LOCAL_VAULT_FORMAT.md
@@ -49,11 +49,20 @@ Cortex.vault/
USER_ID/
SOURCE/
sync_*.json
+ sync_devices/
+ USER_ID/
+ device_*.json
+ sync_receipts/
+ USER_ID/
+ DEVICE_ID/
+ receipt_*.json
memories/
decision/
mem_*.json
+ decision-summary--a1b2c3d4e5f6.md
style/
mem_*.json
+ writing-style--b2c3d4e5f6a1.md
negative/
mem_*.json
preference/
@@ -82,14 +91,34 @@ Cortex.vault/
import/
imp_*.json
attachments/
+ context_packs/
+ HASH_PREFIX/
+ SHA256.json
backups/
cortex-vault-*.zip
exports/
+ People/ # generated, rebuildable Markdown maps
+ Projects/
+ Orgs/
+ Topics/
+ Journal/
+ Cortex — Start Here.md
+ Constellation.canvas
+ credentials.json # private; excluded from vault sync and backups
```
-## Source Of Truth
+## Persistence and Authority
+
+The vault has three persistence layers with different jobs:
-These files are canonical:
+1. Human-readable Markdown is authoritative for accepted, user-editable memory
+ fields.
+2. JSON is authoritative for every non-memory record and is the machine
+ companion/fallback for memories.
+3. SQLite is a derived query index. It is never the only durable copy of a
+ record.
+
+The following JSON and JSONL files are canonical:
- `manifest.json`: vault format, version, index role, and directory contract
- `settings.json`: user behavior settings such as review flow and Ask memory depth
@@ -97,14 +126,44 @@ These files are canonical:
- `imports/**/*.json`: import session history, selected path summaries, source counts, bounded record previews, linked capture IDs, status, errors, and delete markers
- `source_accounts/**/*.json`: connector account metadata, health state, policy metadata, last sync time, last error, and disconnect state
- `sync_cursors/**/*.json`: incremental sync cursor values, high-water marks, per-cursor state, last completion time, and last error
+- `sync_devices/**/*.json` and `sync_receipts/**/*.json`: durable sync-device state and applied-operation receipts
- `captures/**/*.json`: raw source text and capture lifecycle state
-- `memories/**/*.json`: extracted atomic memory records
- `tasks/**/*.json`: open loops and questions
- `entities/**/*.json`: people, projects, organizations, and topics
- `graph_edges/**/*.json`: relationships between sources, memories, tasks, and entities
- `deletion_tombstones/**/*.json`: hard-delete markers that prevent older backups from restoring explicitly deleted records
-
-`index.sqlite` is important but not canonical. If the index is damaged or deleted, Cortex can rebuild it from the vault records.
+- `attachments/**/*`: user-owned attachment bytes linked from durable records
+
+`credentials.json` is a private working file containing connector credentials.
+It is excluded from the generated sync ignore rules and from backup archives.
+`context_packs/` contains immutable, content-addressed audit artifacts; those
+packs may sync with the vault but are not restored as canonical user records.
+The top-level map-of-content pages, `Journal/`, and `Constellation.canvas` are
+generated views and can be regenerated from durable records.
+
+Memory records intentionally have a dual representation:
+
+- `memories/**/*.md` is the user-facing memory. Its body is `content`; accepted
+ YAML frontmatter fields include summary, kind/layer, confidence, importance,
+ status, source references, topics, entity IDs, and temporal fields.
+- `memories/**/*.json` preserves the complete machine record and provides
+ system fields or values not accepted from Markdown. It is also the recovery
+ fallback if a note is missing or cannot be parsed.
+
+On rebuild, records are joined by memory `id`. JSON is loaded first and the
+parsed Markdown fields are overlaid on it, so Markdown wins when the same
+accepted field exists in both. Fields intentionally projected or stripped by
+the Markdown parser—such as generated `tags`, derived `trust_score`,
+`recorded_at`, and `superseded_at`—continue to come from JSON. Generated Links
+and Backlinks sections are stripped while parsing and regenerated; editing
+those generated blocks does not change indexed memory content.
+
+The `id`, `user_id`, and `capture_id` frontmatter values are identity metadata,
+not ordinary content. Do not hand-edit them. A note whose `user_id` no longer
+matches the account being rebuilt is outside that account's rebuild scope.
+
+`index.sqlite` is important but derived. If the index is damaged or deleted,
+Cortex can rebuild it from the durable vault files using the precedence above.
## Record Guarantees
@@ -124,6 +183,11 @@ Import records describe a batch created from selected local paths. They keep pat
Memory records include both `kind` and `layer`. `kind` preserves the atomic type (`claim`, `decision`, `event`, `preference`, `style`, `negative`, `observation`, or `summary`), while `layer` controls retrieval behavior (`semantic`, `episodic`, `style`, `decision`, `preference`, or `negative`).
+Normal Cortex writes persist the JSON companion first and then render the
+Markdown note. A Markdown-rendering failure does not invalidate an already
+durable JSON write; the next backfill can recreate the note. Conversely, a
+valid Markdown-only memory can be added to SQLite during reconciliation.
+
## Rebuild Flow
Use the maintenance endpoint:
@@ -134,15 +198,33 @@ POST /v1/maintenance/rebuild-index-from-vault
The rebuild process:
-1. Reads import sessions, source accounts, sync cursors, captures, memories, tasks, entities, graph edges, settings, and events from the vault.
-2. Clears the current user's SQLite index rows.
-3. Re-inserts normalized records.
-4. Rebuilds FTS rows.
-5. Rebuilds vector rows when `sqlite-vec` is available.
-6. Appends an `index.rebuilt_from_vault` event.
+1. Applies deletion tombstones before loading records.
+2. Reads import sessions, source accounts, sync cursors, captures, tasks,
+ entities, graph edges, settings, and events from JSON/JSONL.
+3. Reads memory Markdown and memory JSON, merges matching IDs with Markdown
+ precedence, and retains JSON-only memories as a recovery fallback.
+4. Clears the current user's derived SQLite rows.
+5. Re-inserts normalized records and rebuilds FTS rows.
+6. Rebuilds vector rows when `sqlite-vec` is available.
+7. Appends an `index.rebuilt_from_vault` event.
This makes support and disaster recovery straightforward: preserve the vault folder, rebuild the index.
+### Hand edits and deletion
+
+Use the vault reconciliation action after editing Markdown. Once the one-time
+Markdown backfill has completed, reconciliation can treat a genuinely missing
+note as a requested deletion. A corrupt-but-present note is preserved for
+recovery, and a safety threshold blocks a suspicious mass disappearance
+instead of tombstoning most of a vault.
+
+Deleting only a `.md` file and immediately calling the raw rebuild endpoint is
+not a hard delete: the rebuild deliberately retains a JSON-only memory. Use
+Cortex's delete flow (or successful post-backfill reconciliation), which
+removes both representations and writes a deletion tombstone. Tombstones take
+precedence during restore and rebuild so an older backup cannot resurrect a
+hard-deleted record.
+
## Legacy Migration
Older local builds used:
@@ -169,7 +251,7 @@ The backend then backfills an empty vault from the copied index by writing captu
- import sessions
- source accounts and sync cursors
- captures
-- memories
+- memory Markdown notes and JSON companions
- tasks
- entities
- graph edges
@@ -179,11 +261,15 @@ The backend then backfills an empty vault from the copied index by writing captu
Backups intentionally exclude prior backups to avoid recursive archives.
-Hard-delete operations remove the selected memory, capture, or import batch from the current SQLite index and current vault JSON records. They also write a deletion tombstone so an older backup cannot silently restore that item later. Capture tombstones also block derived memories, tasks, and graph edges from being restored. Import tombstones block the import session plus captures and derived records linked through `import_id`.
+Hard-delete operations remove the selected memory, capture, or import batch from the current SQLite index and current vault files, including both representations of a memory. They also write a deletion tombstone so an older backup cannot silently restore that item later. Capture tombstones also block derived memories, tasks, and graph edges from being restored. Import tombstones block the import session plus captures and derived records linked through `import_id`.
Backup archives can be removed with `DELETE /v1/backups`, and `DELETE /v1/user-data` removes current user records plus backups by default. New backups also run count-based pruning using `CORTEX_BACKUP_RETENTION_COUNT` (default `20`) and optional age-based pruning with `CORTEX_BACKUP_RETENTION_DAYS` (default `0`, disabled). If a user explicitly keeps backups during a full local reset, item-level tombstones are preserved so future restore operations still honor earlier hard deletes.
-`POST /v1/backups/restore-latest` restores canonical vault records from the newest backup archive, validates member paths before extraction, skips the archived SQLite snapshot, reapplies current deletion tombstones, removes tombstoned records, and rebuilds the current SQLite index from the restored JSON/JSONL files.
+`POST /v1/backups/restore-latest` restores durable vault records from the newest
+backup archive, validates member paths before extraction, skips the archived
+SQLite snapshot, reapplies current deletion tombstones, removes tombstoned
+records, and rebuilds the index from restored Markdown memories plus the
+remaining JSON/JSONL records.
## Privacy Notes
diff --git a/docs/MCP_INTEGRATIONS.md b/docs/MCP_INTEGRATIONS.md
index fd3e1d02..25c4ef4f 100644
--- a/docs/MCP_INTEGRATIONS.md
+++ b/docs/MCP_INTEGRATIONS.md
@@ -1,5 +1,21 @@
# Cortex AI Integrations
+## Fastest path
+
+For the installed macOS app:
+
+1. Open **Connections & Privacy → AI tools**.
+2. Choose Claude Desktop, Cursor, Windsurf, Cline, or Roo Code.
+3. Select **Install**. Cortex backs up and merges the client's existing config.
+4. Restart the AI client.
+5. Ask it to call `ask_memory` or `search_memory`.
+
+Use **Repair** in the same screen if the app moved or the token changed. Do not
+copy the app's admin token into a client; the installer creates a scoped `cxm_`
+MCP token. Client-specific paths and the manual fallback are documented below.
+For connection failures, jump to
+[Troubleshooting](TROUBLESHOOTING.md#an-mcp-client-cannot-connect).
+
## Local Transport
Cortex exposes two MCP-compatible paths:
@@ -28,8 +44,8 @@ catalog, so agents pick the right tool on the first try:
- `get_context` — the context assembly engine: a token-budgeted, cited pack of constraints,
decisions, facts, entity context, procedures, identity, open loops, and recency, shaped by
task intent (`answer`/`act`/`draft`/`plan`/`recall`). Call this first before doing work.
- Also available over REST as `GET`/`POST /v1/context` (read scope; the identity layer alone
- requires export scope and degrades to a visible omission record without it).
+ Also available over REST as `GET`/`POST /v1/context`; the complete pack, including its
+ distilled identity layer, requires read scope.
- `ask_memory` — cite-or-abstain answer to a specific question (never an uncited guess).
- `search_memory` — keyword/semantic search with retrieval diagnostics.
- `get_entity_context` — everything known about one person/project/org/topic + its graph
diff --git a/docs/NAMING_AND_OWNERSHIP.md b/docs/NAMING_AND_OWNERSHIP.md
new file mode 100644
index 00000000..7d548476
--- /dev/null
+++ b/docs/NAMING_AND_OWNERSHIP.md
@@ -0,0 +1,59 @@
+# Naming and Ownership
+
+Status: **Current**
+
+Last reviewed: 2026-07-30
+
+This repository contains historical names from more than one distribution
+surface. Use the following map instead of inventing another namespace.
+
+| Name | Meaning | Where it is canonical |
+|---|---|---|
+| Cortex | Product and open-source project | UI, docs, Python modules, `CORTEX_*` configuration |
+| `trace-cortex/cortex-app` | Canonical source repository | Source links, issues, contribution docs |
+| `doppl-tech/releases` | Current signed binary release repository | Update and direct-download metadata |
+| `api.signindoppl.com` | Current hosted API/auth origin | Hosted examples and deployment configuration |
+| `doppl-cortex-client` | Python SDK distribution name | Python package metadata and install commands |
+| `@doppl-tech/cortex-client` | Planned TypeScript SDK distribution name | npm package metadata and install commands |
+| `@doppl-tech/openclaw-context` | Planned OpenClaw context-engine adapter distribution name | npm package metadata and OpenClaw install docs |
+| `@doppl/cortex-obsidian-plugin` | Private, unpublished historical workspace package name | Obsidian plugin build metadata only |
+| `com.cortex.doppl` | Existing signed macOS bundle identifier | Apple signing, updates, and release manifests |
+
+The `doppl-tech` package namespace, binary-release repository, hosted domain,
+and bundle ID are distribution identifiers; they do not rename the open-source
+project. The planned public npm packages use `@doppl-tech` because `@cortex`
+belongs to an unrelated registry account. Registry availability is not
+ownership: the release operator must create or verify control of the
+`@doppl-tech` npm organization before publishing. The private Obsidian package
+keeps its historical name until it has a public release plan; if published, it
+should migrate to the same verified public scope.
+
+New environment variables, user-facing feature names, and protocol fields
+should use `Cortex` unless a package registry or release surface requires one
+of the distribution identifiers above. Do not casually change the macOS bundle
+ID: signed-app identity, Keychain access, updates, and upgrades depend on it.
+
+## Token Prefixes
+
+Prefixes communicate credential type but are not authorization by themselves:
+
+| Prefix | Credential |
+|---|---|
+| `cxa_` | Scoped Cortex HTTP API token |
+| `cxm_` | Scoped MCP token |
+| `cxs_` | Hosted account access token |
+| `cxr_` | Hosted account refresh token |
+
+Examples must use the prefix matching the surface. Never use a real token in
+documentation, tests, issue reports, or support bundles.
+
+## Ownership Boundaries
+
+- Maintainers of this repository own source compatibility and review.
+- Release operators own signing identities, notarization, hosted secrets,
+ domains, email delivery, and recovery drills.
+- Legal owners must approve terms, privacy language, entity names, and
+ jurisdiction. Source review cannot substitute for that approval.
+- Connector providers own their upstream APIs. A connector is not production
+ ready until its live contract has been exercised with a release-owned test
+ account.
diff --git a/docs/OPEN_SOURCE_READINESS.md b/docs/OPEN_SOURCE_READINESS.md
new file mode 100644
index 00000000..08307575
--- /dev/null
+++ b/docs/OPEN_SOURCE_READINESS.md
@@ -0,0 +1,112 @@
+# Open-Source Readiness Review
+
+Last reviewed: 2026-07-30
+
+Review posture: independent, reject by default
+
+Review scope: the checked-out source tree through the date above. This document
+must be reviewed again before it is reused as evidence for a later release.
+
+This is an evidence-based engineering review of the checked-out source tree,
+not a security certification. It used local source and local test tooling only;
+it did not require repository administration, hosted secrets, or live
+connector accounts.
+
+## Verdict
+
+**Accept for open-source development and external code review. Reject for an
+unqualified production-hosted launch.**
+
+The repository now has credible local setup, tests, examples, security
+boundaries, and operator documentation. Production approval is still blocked
+by unverified external controls and by architectural work that should not be
+hidden behind documentation claims.
+
+## What Was Corrected
+
+- Cross-origin redirects can no longer carry first-party connector, OAuth,
+ model-provider, delivery, OIDC, or SDK bearer credentials to another origin.
+- Webhook delivery connects to the exact public address that passed validation,
+ retaining the original hostname for HTTP and TLS verification.
+- All vault mutations now participate in the same inter-process freeze used by
+ backup, restore, and account deletion. Regression tests cover aggregate
+ multi-process writes, ordinary record writes, and direct Markdown pruning.
+- ZIP imports enforce both member-count and aggregate uncompressed-byte
+ budgets, including nested archives.
+- Password hashing concurrency and in-process rate-limiter cardinality are
+ bounded.
+- Hosted readiness fails closed when credential encryption enforcement or its
+ runtime keyring is absent, the complete credential scan is truncated or
+ unreadable, or any plaintext credential remains.
+- Large synchronous exports use a conservative Unicode-aware storage preflight,
+ default to a 25 MB source-data cap, and fail with a bounded `413` response
+ instead of materializing an unbounded corpus.
+- Backup snapshots coordinate with vault writers and publish atomically.
+- Public signup fails closed until the operator records legal approval; password
+ and OAuth signup consent is explicit and OAuth consent is bound into the
+ single-use state.
+- `cxa_` HTTP API tokens work on the REST-style `/v1/tools/*` surface while
+ `/mcp` remains restricted to `cxm_` MCP tokens.
+- `POST /v1/context` is typed and bounded. OpenAPI operation IDs are stable
+ method/path identifiers, unique in tests, and protected routes publish a
+ bearer security scheme.
+- Mac mini setup uses a dedicated Python 3.12 virtual environment.
+- Examples include an idempotent, loopback-only synthetic seed.
+- Privacy and terms copy no longer says all memory content is encrypted when
+ the implementation currently guarantees encryption only for hosted
+ connector credentials.
+- SDK/plugin licenses and token examples now agree with repository policy.
+
+## Priority Findings
+
+| Priority | Finding | Current disposition |
+|---|---|---|
+| P0 | Multi-process vault read-modify-write lost data | Fixed and regression-tested |
+| P0 | Public privacy copy overstated encryption scope | Corrected; legal approval still required |
+| P1 | Credential headers could cross origins on redirects | Fixed across backend and SDK transports |
+| P1 | Hosted credential encryption could fail open | Readiness now requires complete runtime scan evidence and zero remaining plaintext records |
+| P1 | ZIP and export paths could exhaust memory | ZIP fixed; synchronous export bounded; streaming/background export remains future work |
+| P1 | Argon2 bursts could exhaust process memory | Per-process concurrency bounded; deployment worker count still needs capacity planning |
+| P1 | Backup could race with vault writes | Fixed with coordinated locks and atomic archive publication |
+| P1 | OAuth could create an account without explicit current legal consent | Fixed with signup gating and consent-bound single-use state |
+| P1 | Webhook DNS could change after SSRF validation | Fixed by pinning the validated address through the connection |
+| P1 | Existing memory-count quota is check-before-write, raceable, and batch/queue blind; stored bytes are unbounded | Open production blocker; move reservation/enforcement into the storage transaction |
+| P1 | Export preflight and serialization do not share one database snapshot; portable proof re-exports | Open consistency and memory-safety blocker |
+| P1 | Signup consent is not stored with policy version/hash and acceptance time | Open legal-audit blocker |
+| P1 | `backend/app/storage.py` is an oversized subsystem with broad responsibilities | Open maintainability risk; split only with characterization tests |
+| P2 | Python runtime dependencies allow version ranges | Open reproducibility risk; audit now covers the runtime manifest |
+| P2 | Several GitHub Actions use mutable major-version tags | Open supply-chain hardening item; pin reviewed commit SHAs |
+
+## Evidence Required Before Production Approval
+
+1. Replace route-level memory quota checks with atomic storage-transaction
+ reservation/enforcement based on actual new active IDs; cover concurrent
+ captures, multi-memory extraction, sync batches, idempotent upserts, and
+ queued jobs. Add an enforced stored-byte quota as part of the same control.
+2. Run the credential migration and retain release evidence that hosted
+ readiness observed a complete, readable scan with zero plaintext records.
+3. Put export estimate, filtering, imports, stats, payload, and portable proof
+ on one database snapshot; derive the proof from the already-built payload,
+ then replace large in-memory exports with streaming or background jobs.
+4. Persist the accepted Terms/Privacy version, policy hash, age assertion, and
+ acceptance timestamp on the account and in the audit event.
+5. Characterize and split the storage/API god modules without changing their
+ contracts.
+6. Lock packaged runtime dependencies and pin third-party CI actions to reviewed
+ commit SHAs.
+7. Have counsel approve legal entity, jurisdiction, terms, and privacy copy.
+8. Exercise OAuth, email delivery, domains, and every supported connector with
+ release-owned accounts.
+9. Complete an encrypted backup/restore drill with separately escrowed keys.
+10. Verify signed artifacts, notarization, update metadata, and rollback on the
+ actual release channel.
+11. Run load/soak tests using the intended worker count, storage topology, and
+ realistic corpus sizes.
+
+## Review Standard
+
+Production approval requires evidence, not configuration-shaped objects or
+optimistic prose. A test that simulates a keyring is useful for contract
+coverage but is not proof that a deployment holds the right key. Likewise,
+synthetic retrieval benchmarks guard regressions but do not establish product
+quality for a diverse user population.
diff --git a/docs/OPERATIONAL_READINESS.md b/docs/OPERATIONAL_READINESS.md
index 4fdaaf04..b044e778 100644
--- a/docs/OPERATIONAL_READINESS.md
+++ b/docs/OPERATIONAL_READINESS.md
@@ -1,6 +1,7 @@
# Cortex Operational Readiness
-This document defines how Cortex should be operated for the local-first macOS beta before a hosted backend exists.
+This document defines how Cortex should be operated for the local-first macOS
+beta and its supporting hosted account/sync plane.
## Operating Model
@@ -11,7 +12,8 @@ Cortex beta operations are local-first:
- releases are packaged as DMG and ZIP artifacts
- the update feed is a static `latest.json`
- support triage starts from a sanitized support bundle
-- public incident response is manual until hosted accounts and telemetry exist
+- public incident response is manual until production telemetry, alerting, and
+ formal ownership exist
The goal is not to pretend we have production cloud operations. The goal is to make the local beta repeatable, supportable, and recoverable.
@@ -201,7 +203,7 @@ Examples:
Immediate response:
1. Run `python3 scripts/check_distribution_site.py`.
-2. Run `python3 scripts/validate_update_manifest.py site/downloads/latest.json`.
+2. Run `python3 scripts/validate_update_manifest.py --allow-remote-artifacts site/downloads/latest.json`.
3. Re-run `python3 scripts/prepare_distribution_site.py`.
4. Re-test the static site locally.
5. Replace hosted files atomically: manifest after artifacts.
diff --git a/docs/PHASE2_SYNC_DESIGN.md b/docs/PHASE2_SYNC_DESIGN.md
index faf4a75b..c64a9f1a 100644
--- a/docs/PHASE2_SYNC_DESIGN.md
+++ b/docs/PHASE2_SYNC_DESIGN.md
@@ -1,7 +1,3 @@
-I have verified all the load-bearing claims against the actual code. I have everything I need to produce the design.
-
----
-
# Phase-2 Local→Hosted Background Push-Sync — Implementation Design
All claims below are grounded in the actual code. Key verified facts:
@@ -216,4 +212,4 @@ Reuse `beginMenuBarWork/endMenuBarWork` (`CortexApp.swift:3971/3982`) around the
- **Local backend not running:** the local read leg goes through `request()` (`CortexApp.swift:7293`), which already restarts the backend on retriable connection errors for idempotent GETs (`CortexApp.swift:7312-7316`). If still down, tick fails cleanly, cursor untouched, retries next cadence.
- **Cursor integrity:** using `captures.rowid` (monotonic int) instead of the event-hash cursor avoids the ISO-timestamp tiebreaker ties and the `cursor_not_found` reset (`storage.py:15716`) that plague `/v1/sync/changes`. Cursor is cleared on sign-out so a new account never inherits a stale watermark.
-**Files to touch:** `backend/app/models.py:11`, `backend/app/main.py:645` & ~`:1962`, `backend/app/storage.py` (new `capture_change_page`; reuse `save_capture:8887`, `record_sync_receipt`), `macos/Sources/CortexPushSync.swift` (new), `macos/Sources/CortexApp.swift:3625,3994,2981`, `macos/Sources/CortexCloudAuth.swift:471,522,625`.
\ No newline at end of file
+**Files to touch:** `backend/app/models.py:11`, `backend/app/main.py:645` & ~`:1962`, `backend/app/storage.py` (new `capture_change_page`; reuse `save_capture:8887`, `record_sync_receipt`), `macos/Sources/CortexPushSync.swift` (new), `macos/Sources/CortexApp.swift:3625,3994,2981`, `macos/Sources/CortexCloudAuth.swift:471,522,625`.
diff --git a/docs/PORTABLE_MEMORY_PROTOCOL_V2.md b/docs/PORTABLE_MEMORY_PROTOCOL_V2.md
index 99b56b8a..cdc1a65c 100644
--- a/docs/PORTABLE_MEMORY_PROTOCOL_V2.md
+++ b/docs/PORTABLE_MEMORY_PROTOCOL_V2.md
@@ -4,7 +4,7 @@ Status: **published implementation specification**
Protocol name: `cortex-portable-memory`
Protocol version: `2`
Outer bundle version: `v3`
-Reference implementations: Cortex Python backend and `@cortex/openclaw-context` TypeScript verifier
+Reference implementations: Cortex Python backend and `@doppl-tech/openclaw-context` TypeScript verifier
## 1. Purpose
diff --git a/docs/PRODUCTION_READINESS.md b/docs/PRODUCTION_READINESS.md
index 6019c1b5..f7e80ce7 100644
--- a/docs/PRODUCTION_READINESS.md
+++ b/docs/PRODUCTION_READINESS.md
@@ -2,9 +2,15 @@
## Current Goal
-This repo is now a local-first beta candidate: useful enough for daily dogfooding on macOS, hardened enough to protect local user memory during a controlled beta, and shaped so a hosted backend can replace local SQLite later without changing the client contract.
+This repo is now a local-first beta candidate: useful enough for daily
+dogfooding on macOS, hardened enough to protect local user memory during a
+controlled beta, and supported by a hosted account/sync plane without moving
+local retrieval out of the app.
-It is not yet a broad public launch or millions-of-users hosted system. That requires hosted auth, a managed multi-tenant database, remote MCP/OAuth, billing/quotas, observability, incident response, and notarized distribution.
+It is not yet a broad public launch or millions-of-users hosted system. The
+direct macOS beta is signed and notarized, but broad launch still requires
+production hardening of hosted auth and storage, remote MCP/OAuth,
+billing/quotas, observability, formal support, and incident response.
## Productization Status
@@ -34,12 +40,14 @@ The remaining roadmap should stay non-overengineered:
- prove daily value with real connected sources, approvals, and cited Ask results;
- keep improving connector quality, chunking, reranking, citation paths, and review ergonomics before adding automation;
- package and support a controlled macOS beta with repeatable checks, backups, and sanitized support bundles;
-- add notarization, hosted downloads, update-feed policy, and formal support before external distribution;
-- defer hosted accounts, remote MCP/OAuth, teams, billing, and enterprise controls until the local MCP/Obsidian loop is consistently useful.
+- maintain signed/notarized hosted downloads and a verified update feed, then
+ add formal support before broader external distribution;
+- defer broad-scale hosted operations, remote MCP/OAuth, teams, billing, and
+ enterprise controls until the local memory loop is consistently useful.
## Local Production Guarantees
-- User-approved MCP and Obsidian sync only; no background app crawling
+- Explicitly connected sources only; no ambient or background app crawling
- API-token protected local endpoints
- User-owned local vault folder with JSON records and append-only events
- Content-free local sync change feed for event ordering, counts, safe metadata, and future hosted materialization planning
@@ -77,7 +85,7 @@ The remaining roadmap should stay non-overengineered:
## Pre-Hosted Backend Checklist
- Dogfood with at least 1,000 captures across clipboard, notes, ChatGPT, Claude, docs, and meetings
-- Run `python3 -m unittest discover backend/tests` before every app package
+- Run `python3 -m pytest backend/tests -q` before every app package
- Run `python3 scripts/retrieval_eval.py` before every app package
- Run `python3 scripts/check_connector_baseline.py` before claiming the 10k baseline connector set is intact
- Run `python3 scripts/backend_beta_smoke.py` before inviting beta users; it must pass the Obsidian/MCP -> Review -> Ask loop with sockets blocked
@@ -103,7 +111,8 @@ The remaining roadmap should stay non-overengineered:
- Verify `python3 scripts/ops_readiness_check.py --refresh-site` passes before inviting testers
- Verify the inbox is still understandable with 100+ pending captures
- Verify the graph remains useful and responsive with thousands of nodes
-- Add app notarization and signed installer before external distribution
+- Keep Developer ID signing, notarization, release manifests, and hosted
+ artifact checks in the release gate
- Add a user-facing "Open Vault Folder", "Change Vault Location", and "Restore Backup" flow
## Hosted Backend Requirements
diff --git a/docs/README.md b/docs/README.md
new file mode 100644
index 00000000..375ceeff
--- /dev/null
+++ b/docs/README.md
@@ -0,0 +1,80 @@
+# Cortex Documentation
+
+This index separates the current product contract from operator runbooks,
+forward-looking designs, and experimental feature branches. It was last
+audited against the checked-out source on 2026-07-30. It does not claim that
+unmerged branch content is already in `main`.
+
+## Status Rules
+
+- **Current** documents describe behavior implemented in `main`.
+- **Operations** documents describe how the current macOS beta is built,
+ verified, distributed, and supported.
+- **Design** documents preserve intended architecture or migration paths. They
+ are not launch promises unless the code and current-product docs agree.
+- **Experimental** documents describe work outside `main`.
+
+When documents disagree, use this order of authority:
+
+1. executable tests and release manifests;
+2. current API and application code;
+3. current and operations documents;
+4. design documents.
+
+## Current Product
+
+| Area | Documentation |
+|---|---|
+| Architecture and code map | [ARCHITECTURE.md](ARCHITECTURE.md) · [CODE_MAP.md](CODE_MAP.md) |
+| API lifecycle and naming | [API_LIFECYCLE.md](API_LIFECYCLE.md) · [NAMING_AND_OWNERSHIP.md](NAMING_AND_OWNERSHIP.md) |
+| Product loop and onboarding | [SIMPLE_PRODUCT_LOOP.md](SIMPLE_PRODUCT_LOOP.md) · [FIRST_RUN_ONBOARDING.md](FIRST_RUN_ONBOARDING.md) |
+| Local vault | [LOCAL_VAULT_FORMAT.md](LOCAL_VAULT_FORMAT.md) |
+| Context protocol | [CMP_PROTOCOL.md](CMP_PROTOCOL.md) · [PORTABLE_MEMORY_PROTOCOL_V2.md](PORTABLE_MEMORY_PROTOCOL_V2.md) |
+| Source ingestion and extension | [SOURCE_IMPORTS.md](SOURCE_IMPORTS.md) · [ADDING_A_CONNECTOR.md](ADDING_A_CONNECTOR.md) · [CAPTURE_SURFACES.md](CAPTURE_SURFACES.md) |
+| MCP and external tools | [MCP_INTEGRATIONS.md](MCP_INTEGRATIONS.md) · [EXTERNAL_INTEGRATIONS.md](EXTERNAL_INTEGRATIONS.md) |
+| Connector readiness | [CONNECTOR_COVERAGE_READINESS.md](CONNECTOR_COVERAGE_READINESS.md) |
+| Trust and privacy | [TRUST_CONTROLS.md](TRUST_CONTROLS.md) · [SECURITY_REVIEW.md](SECURITY_REVIEW.md) |
+| Benchmarks | [BENCHMARKS.md](BENCHMARKS.md) |
+| Open-source readiness | [OPEN_SOURCE_READINESS.md](OPEN_SOURCE_READINESS.md) |
+| FAQ and troubleshooting | [FAQ.md](FAQ.md) · [TROUBLESHOOTING.md](TROUBLESHOOTING.md) |
+| Optional encrypted sync | [E2EE_SYNC_DESIGN.md](E2EE_SYNC_DESIGN.md) · [CXE1_WIRE_FORMAT.md](CXE1_WIRE_FORMAT.md) |
+
+## Release and Operations
+
+| Area | Documentation |
+|---|---|
+| Install and updates | [INSTALLER_AND_UPDATES.md](INSTALLER_AND_UPDATES.md) |
+| Distribution | [DISTRIBUTION.md](DISTRIBUTION.md) |
+| Apple release paths | [APPLE_RELEASE.md](APPLE_RELEASE.md) |
+| Operational readiness | [OPERATIONAL_READINESS.md](OPERATIONAL_READINESS.md) |
+| Production readiness | [PRODUCTION_READINESS.md](PRODUCTION_READINESS.md) |
+
+The canonical source repository is
+[`trace-cortex/cortex-app`](https://github.com/trace-cortex/cortex-app). Current
+signed binaries are published through
+[`doppl-tech/releases`](https://github.com/doppl-tech/releases/releases); the
+committed [`site/downloads/latest.json`](../site/downloads/latest.json) is the
+source of truth for the downloadable build and its signing state.
+
+## Design and Migration Documents
+
+These preserve future paths and should not be read as proof that a feature is
+already enabled:
+
+- [ACCOUNTS_ENCRYPTION_DESIGN.md](ACCOUNTS_ENCRYPTION_DESIGN.md)
+- [PHASE2_SYNC_DESIGN.md](PHASE2_SYNC_DESIGN.md)
+- [NATIVE_VAULT_PLAN.md](NATIVE_VAULT_PLAN.md)
+- [MEMORY_BACKEND_BLUEPRINT.md](MEMORY_BACKEND_BLUEPRINT.md)
+- [SQLITE_VEC_BACKEND_PLAN.md](SQLITE_VEC_BACKEND_PLAN.md)
+- [DATA_INGESTION_SURVEY.md](DATA_INGESTION_SURVEY.md)
+
+## Experimental Work
+
+Pairwise digital-twin evaluation is implemented and documented on
+`feat/pairwise-twin-eval`; it is not part of the current `main` release:
+
+- [Pairwise evaluation overview](https://github.com/trace-cortex/cortex-app/blob/feat/pairwise-twin-eval/docs/PAIRWISE_TWIN_EVALUATION.md)
+- [Pairwise integration guide](https://github.com/trace-cortex/cortex-app/blob/feat/pairwise-twin-eval/docs/PAIRWISE_TWIN_INTEGRATION_GUIDE.md)
+
+The feature remains experimental until its owner study and production-admission
+criteria are satisfied.
diff --git a/docs/RELIABILITY_HARDENING.md b/docs/RELIABILITY_HARDENING.md
index 0bc4f648..ba9722c3 100644
--- a/docs/RELIABILITY_HARDENING.md
+++ b/docs/RELIABILITY_HARDENING.md
@@ -97,7 +97,7 @@ Maintenance tools still pass through Trust controls.
## QA Commands
```bash
-python3 -m unittest discover backend/tests
+python3 -m pytest backend/tests -q
python3 scripts/reliability_check.py --base-url http://127.0.0.1:8766 --token "$CORTEX_API_KEY"
python3 scripts/first100_live_smoke.py --base-url http://127.0.0.1:8766
python3 scripts/battle_test_http.py --base-url http://127.0.0.1:8766 --token "$CORTEX_API_KEY"
diff --git a/docs/SECURITY_REVIEW.md b/docs/SECURITY_REVIEW.md
index 1cf3eeff..3f7b7819 100644
--- a/docs/SECURITY_REVIEW.md
+++ b/docs/SECURITY_REVIEW.md
@@ -1,5 +1,62 @@
# Security review — accounts / OAuth / data surface (2026-07-07)
+> Follow-up review: 2026-07-30. The original review below is retained for
+> history; its earlier "no IDOR found" conclusion did not cover server-local
+> path inputs and was incomplete. The follow-up closed the discovered
+> boundaries and added regression tests.
+
+## Follow-up fixed (2026-07-30)
+
+### CRITICAL — hosted path inputs could read API-server files
+
+Hosted import analysis/import, Obsidian sync/write-back, coding-agent session
+harvesting, and calendar file inputs accepted paths on the API server. In a
+sharded deployment, that confused a user's local-machine boundary with the
+server's filesystem and could expose cross-tenant or deployment data readable
+by the service account.
+
+**Fix:** all path-based operations now fail closed unless
+`CORTEX_SHARD_MODE=local`. The check exists at the FastAPI boundary and again
+inside sharded `CortexStore` instances so scheduled jobs and MCP/tool calls
+cannot bypass it. Hosted imports must use uploaded content rather than server
+paths. Regression coverage:
+`backend/tests/test_hosted_trust_boundaries.py`.
+
+### HIGH — connector credentials could be sent to arbitrary origins
+
+Credential-bearing connector requests accepted caller-controlled
+`api_base_url`, `api_url`, Jira `site_url`, calendar feeds, and OAuth token
+endpoints. That created SSRF and credential-exfiltration paths.
+
+**Fix:** hosted connector calls now allow only each provider's canonical HTTPS
+API origin. Hosted Jira is restricted to HTTPS `*.atlassian.net`; local-only
+Zotero, calendar feed/file, Obsidian, and agent-session operations are blocked
+on sharded stores. Custom OAuth token endpoints are rejected by hosted HTTP
+routes. The storage-level policy also covers saved scheduled connector jobs and
+MCP/tool calls. Local mode retains explicit test/development overrides.
+
+### HIGH — backups contained plaintext memory and production secrets
+
+The server backup copied `/etc/cortex/cortex.env` and produced an unencrypted
+archive. **Fix:** the environment file is excluded, backup directories and
+files use `0700`/`0600` permissions under `umask 077`, and the script refuses
+to run without an `age` public recipient. Bootstrap generates a dedicated age
+identity whose private half must be escrowed separately; offsite copies contain
+only encrypted archives.
+
+### HIGH — public deployment defaults auto-verified email
+
+Both hosted environment templates now default to
+`CORTEX_AUTH_AUTOVERIFY=0` and SMTP mode. Existing installations must migrate
+their persisted environment file; changing the example does not rewrite an
+already-provisioned deployment.
+
+### MEDIUM — query-string capture could leak tokens and content to logs
+
+`GET /capture` accepted a token and captured content in the URL. Hosted mode
+now rejects query-string capture and requires `POST /capture`; the local
+compatibility surface remains available for loopback clients.
+
Adversarial review across 6 lenses (auth/session, OAuth+SSRF, multi-tenant isolation, admin/secrets,
injection, macOS sandbox). Every candidate finding was independently verified; false positives
dropped. **5 confirmed** findings. Multi-tenant isolation, admin auth, injection, and the macOS
@@ -33,11 +90,11 @@ URL. Now both require an `http(s)` scheme before opening.
## Deferred (documented; not yet fixed)
-### MEDIUM — `CORTEX_AUTH_AUTOVERIFY=1` enables email squatting
+### MEDIUM — `CORTEX_AUTH_AUTOVERIFY=1` enables email squatting (deployment default fixed 2026-07-30)
Beta profile skips email verification, so an attacker can pre-register a victim's email + password;
when the real owner later tries Google/Apple, the "never silent auto-link" rule returns
`link_required` and effectively locks them out of their own address.
-**Fix at public launch:** set `CORTEX_AUTH_AUTOVERIFY=0` and require a real emailed verify token
+**Required operator action:** set `CORTEX_AUTH_AUTOVERIFY=0` and require a real emailed verify token
(the flow already exists) before an account becomes active — i.e. wire real email (Postmark/SES).
Acceptable for a closed beta; **must** be flipped before opening signups broadly.
@@ -47,8 +104,9 @@ when present. Replay is already bounded by the token's audience (our bundle id)
Hardening (server-issued challenge nonce round-tripped by the native client) is optional; low risk.
## Verified clean
-- **Multi-tenant isolation:** per-user shard resolution + scoped-token enforcement (`require_agent_access`)
- on every data path; no IDOR found.
+- **Multi-tenant identity routing:** per-user shard resolution + scoped-token enforcement
+ (`require_agent_access`) were clean in the original scope. The later
+ server-filesystem issue above was outside that scope and is now regression-tested.
- **Admin:** `admin_auth` is a constant-time compare of `CORTEX_API_KEY`; a user token cannot reach
`/v1/admin/*`; no secret leaks in responses.
- **Injection:** account/metrics SQL is parameterized (incl. the new `last_active_at` subqueries and
diff --git a/docs/SOURCE_IMPORTS.md b/docs/SOURCE_IMPORTS.md
index d09461ce..368c19c8 100644
--- a/docs/SOURCE_IMPORTS.md
+++ b/docs/SOURCE_IMPORTS.md
@@ -1,6 +1,23 @@
# Source Ingestion
-Cortex source ingestion turns connected services, local app connectors, MCP bridges, and support fallback imports into normalized memory candidates. The first-100-user product should lead with connected paths that can register a source account, stream records through `/v1/source-accounts/{account_id}/sync`, preserve citations, and route new records through Review.
+> **Choose your path:** To send records from an existing integration or build a
+> new first-party source, use the complete
+> [connector guide](ADDING_A_CONNECTOR.md). This page is the product support
+> matrix and source-account contract reference.
+
+> **Current connector note (audited 2026-07-30):** In addition to the export
+> parsers below, the backend now has read-only live connectors for GitHub,
+> Gmail, Google Drive, Outlook, Slack, Readwise, Calendar, Raindrop, Zotero,
+> Linear, Jira, Notion, and Obsidian. GitHub supports device-flow sign-in.
+> Google, Microsoft, and Notion managed OAuth flows exist in code, but the
+> direct build currently ships their client IDs empty; those connectors
+> therefore use a user-supplied token/key unless a release configures OAuth.
+
+Cortex source ingestion turns connected services, local app connectors, MCP
+bridges, and support fallback imports into normalized memory candidates. The
+current product leads with connected paths that register a source account,
+stream records through `/v1/source-accounts/{account_id}/sync`, preserve
+citations, and route new records through Review.
Local export/file import still exists as backend infrastructure for tests, migrations, unsupported services, and support recovery. It should not be the normal first-run product path.
@@ -60,7 +77,10 @@ GET /v1/sources/readiness
A connector sends records with `content`, optional `title`, original `source_url`, `external_id`, `captured_at`, and metadata. If `source_url` is absent, Cortex generates a stable `source-account://{source}/{account_id}/{external_id}` locator. `processing: "sync"` extracts immediately; `processing: "async"` stores raw captures and queues extraction.
-The response reports saved, queued, skipped, failed, generated capture IDs, per-record statuses, and the updated cursor. This is the contract that should power local app integrations, MCP bridges, and future Gmail, Notion, Slack, Drive, Calendar, GitHub, Mail, Messages, browser, and AI-tool connectors.
+The response reports saved, queued, skipped, failed, generated capture IDs,
+per-record statuses, and the updated cursor. The shipped read-only connectors,
+local app integrations, and MCP bridges use this contract; new connectors
+should extend it instead of inventing a second ingestion path.
For account-backed sync, `source_account_id + external_id` is the durable record identity. Re-syncing the same external record with unchanged content is skipped; re-syncing it with changed content replaces the capture's derived memories, tasks, graph edges, and queued extraction work under the same capture ID. Two different external records are allowed to produce separate captures even if their current text is identical, because service records often share boilerplate, signatures, or short repeated status text.
@@ -122,7 +142,14 @@ Repeated fallback imports are idempotent by content hash and source. If a fallba
## Source Account Registry
-Cortex keeps durable local connector state for connected-source sync. The macOS app now includes a first native local connector for Obsidian vaults: the user grants a vault folder once, Cortex scans Markdown/text notes, registers a source account, streams cited records through `/v1/source-accounts/{account_id}/sync`, advances a cursor, and lets Review decide what becomes trusted memory. Other source-specific OAuth/sign-in UI is still implemented connector by connector, but the shared backend contract is in place for local app integrations, MCP bridges, and connector processes.
+Cortex keeps durable local connector state for connected-source sync. Obsidian
+is the native local-folder path: the user grants a vault folder once, Cortex
+scans Markdown/text notes, registers a source account, streams cited records
+through `/v1/source-accounts/{account_id}/sync`, advances a cursor, and lets
+Review decide what becomes trusted memory. The same contract now backs the
+read-only token/API connectors listed in the note above. GitHub device flow is
+configured; managed Google, Microsoft, and Notion OAuth needs release-provided
+client IDs.
Connector capability catalog:
@@ -133,7 +160,12 @@ GET /v1/sources/readiness
The catalog lists common services such as ChatGPT, Claude, Gmail, Apple Mail, Outlook, email files, docs, PDFs, cloud-doc exports, Notion, Google Drive, Google Docs, Google Keep, Microsoft 365, Slack, Google Chat, Teams, Discord, Telegram, Messages, iMessage exports, WhatsApp, Calendar, Contacts, GitHub, Linear, Jira, Zoom, Browser Bookmarks, browser history exports, Readwise, Raindrop, Zotero, LinkedIn, Twitter/X, Apple Notes, and Obsidian. Each entry includes current connector readiness, future live-sync status, auth type, scopes, supported formats, `export_status`, and the canonical `source_ids` that captures and memories will use.
-Branded connectors can map to canonical memory sources. For example, Gmail, Apple Mail, and Outlook mail records can map to `email`, Google Drive and Google Docs records can map to `cloud-docs` or `docs`, PDFs map to `docs`, iMessage records map to `messages`, GitHub records map to `github` or `work-tools`, and Readwise/Raindrop/Zotero records map to their branded sources or `knowledge-base`. Connections & Privacy should show working account/local-note connections first and keep export/file import under Advanced/Fallback when a source cannot connect directly yet.
+Branded connectors can map to canonical memory sources. For example, Gmail,
+Apple Mail, and Outlook mail records can map to `email`; Google Drive and
+Google Docs records can map to `cloud-docs` or `docs`; and GitHub records can
+map to `github` or `work-tools`. Connections & Privacy shows working
+account/local-note connections first and keeps export/file import under
+Advanced/Fallback when a source cannot connect directly yet.
See `docs/CONNECTOR_COVERAGE_READINESS.md` for the first-100-user beta coverage map and the later live OAuth readiness gates.
@@ -202,12 +234,12 @@ When aliases match a Slack handle/name or email sender, that text can seed user-
## Product Flow
-Connections & Privacy should present only useful connection paths in the default flow. For the first-100-user checkpoint, those are:
-
-- MCP AI tools for connected assistant access and memory writes.
-- Obsidian vault sync as the first native local notes connector.
-
-Future source rows should follow the same pattern: connect or authorize the source, register a source account, sync records, then let Review decide what becomes trusted memory. Manual sync controls, export/file import, and copy-oriented fallback flows should stay inside Advanced/Fallback, not Home, Review, or Ask.
+Connections & Privacy presents connection paths whose catalog readiness is
+implemented. Each follows the same product loop: connect or authorize the
+source, register a source account, sync records, then let Review decide what
+becomes trusted memory. MCP AI-tool setup and Obsidian local-folder sync remain
+the provider-neutral paths. Export/file import and copy-oriented fallback flows
+stay inside Advanced/Fallback rather than Home, Review, or Ask.
## Privacy Boundaries
@@ -222,8 +254,13 @@ Future source rows should follow the same pattern: connect or authorize the sour
## Current Limits
-- The generic source-account sync ingestion endpoint is implemented; branded OAuth/API sign-in flows still need to be built source by source for Gmail, Notion, Slack, Google Chat, Google Drive, Microsoft 365, Teams, Linear, Jira, GitHub, LinkedIn, Twitter/X, Zoom, and browser history.
+- The generic source-account sync ingestion endpoint and thirteen read-only
+ connector modules are implemented. Remaining gaps include configured
+ Google/Microsoft/Notion OAuth clients, managed OAuth for the other token
+ connectors, and direct connectors for Google Chat, Teams, LinkedIn,
+ Twitter/X, Zoom, and browser history.
- Advanced/Fallback import remains available for services that cannot connect directly yet.
- PDF extraction depends on optional `pypdf`; otherwise the macOS fallback can extract PDFs selected through the app.
-- Very large exports are capped by record count and per-record character limits, then chunking/reranking should be improved in the next ingestion pass.
+- Very large exports are capped by record count and per-record character limits;
+ chunking and ranking quality at those limits remain an open evaluation area.
- The importer normalizes data into candidate captures; extraction quality still depends on the local heuristic extractor or the configured LLM extractor.
diff --git a/docs/TROUBLESHOOTING.md b/docs/TROUBLESHOOTING.md
new file mode 100644
index 00000000..3f2e2076
--- /dev/null
+++ b/docs/TROUBLESHOOTING.md
@@ -0,0 +1,95 @@
+# Troubleshooting
+
+## `make setup` says Python 3.12 is missing
+
+The macOS system `python3` may be 3.9 and is not supported by this repository.
+On a Homebrew-based Mac:
+
+```bash
+brew install python@3.12
+make doctor
+make demo
+```
+
+On another platform, install Python 3.12 with your system or Python version
+manager, then point Cortex at it:
+
+```bash
+make doctor PYTHON=/absolute/path/to/python3.12
+make demo PYTHON=/absolute/path/to/python3.12
+```
+
+## `make run` cannot import FastAPI or uvicorn
+
+Run `make setup` first. `make run` intentionally uses `.venv/bin/python` instead
+of silently falling back to the system interpreter.
+
+## Port 8766 is already in use
+
+The packaged app may already be running. Either quit Cortex or use a different
+development port:
+
+```bash
+make run
+```
+
+On a collision, `make run` prints an actually available port and the exact
+`CORTEX_BASE_URL` export for clients. Re-run the suggested command instead of
+relying on a fixed fallback port, which may also be occupied.
+
+## The app opens but the full local engine is unavailable
+
+The default `./macos/build.sh` output is a development/UI build without the
+bundled Python interpreter or Model2Vec weights. A release-like build requires
+the Python 3.12 framework and `CORTEX_BUNDLE_PYTHON=1`; follow
+[APPLE_RELEASE.md](APPLE_RELEASE.md). Do not distribute an ad-hoc development
+build as if it were the notarized release.
+
+## First launch is blocked by sign-in
+
+The direct build sets `CortexRequireAccount=true`. Confirm the Mac can reach the
+configured hosted API and that system time is correct. The sample-notes preview
+is temporary and does not disable the requirement for later launches.
+
+## Ask returns an abstention or empty result
+
+1. Confirm the source shows a successful sync.
+2. Approve relevant pending items in Review.
+3. Ask a specific question using terms present in the source.
+4. Check storage/retrieval health in Connections & Privacy.
+
+An abstention is expected when Cortex cannot attach relevant citations.
+
+## Semantic retrieval reports the hash fallback
+
+Development builds may not contain the Model2Vec package or weights. Release
+packaging enforces the real model. Use `scripts/check_vector_runtime.py` against
+the packaged app before making semantic-retrieval claims.
+
+## An MCP client cannot connect
+
+- Confirm Cortex is running and `/health` responds on loopback.
+- Reinstall the integration from Connections & Privacy.
+- Restart the client after its config changes.
+- Check that the token is scoped for the requested operation.
+- Never paste the token into a public issue.
+
+See [MCP_INTEGRATIONS.md](MCP_INTEGRATIONS.md) for client-specific paths.
+
+## A connector sign-in option is missing
+
+GitHub device flow is configured. Google, Microsoft, and Notion managed OAuth
+flows require client IDs that are empty in the current direct build. Use the
+documented token/export fallback; see
+[CONNECTOR_COVERAGE_READINESS.md](CONNECTOR_COVERAGE_READINESS.md).
+
+## I need help without sharing memory
+
+Generate a sanitized support bundle from Connections & Privacy or:
+
+```bash
+.venv/bin/python scripts/export_support_bundle.py --mode live
+```
+
+Review it before sharing. Never attach the vault, raw exports, or credentials.
+See [SUPPORT.md](../SUPPORT.md).
diff --git a/docs/benchmarks/2026-07-30-local.json b/docs/benchmarks/2026-07-30-local.json
new file mode 100644
index 00000000..7109e49e
--- /dev/null
+++ b/docs/benchmarks/2026-07-30-local.json
@@ -0,0 +1,93 @@
+{
+ "schema_version": 1,
+ "recorded_at": "2026-07-30",
+ "purpose": "deterministic regression snapshot; not a production latency or quality claim",
+ "source": {
+ "baseline_commit": "8eedd5ceab5c1410c714a0c1ee19f7b1a25864e5",
+ "working_tree_dirty": true,
+ "branch": "docs/repo-refresh"
+ },
+ "environment": {
+ "python": "3.12.13",
+ "os": "Darwin 25.5.0 arm64",
+ "hardware": "Apple M1 Pro, 10 cores, 16 GB RAM",
+ "network_used": false,
+ "model_api_calls": false,
+ "retrieval_provider": "hash",
+ "semantic_model_asset_present": false
+ },
+ "method": {
+ "repetitions": 1,
+ "timing_captured": false,
+ "cache_state": "not controlled",
+ "configuration": "repository defaults and checked-in synthetic fixtures"
+ },
+ "harness_sha256": {
+ "scripts/retrieval_eval.py": "142fe72f413e6512cd0438ec64871012ecbeeb8e4c7a8aceef43faed5fe2636c",
+ "scripts/context_pack_eval.py": "5afda9a70d77264778b13a504f7d2668543031c0a0d2d7e53bc01cef6b6146fd",
+ "scripts/adaptation_eval.py": "35f5ebf44d01cfd5adf49b557b2ea1c1791ddc97182f901928490457cc7c53b9",
+ "scripts/token_calibration_eval.py": "ff7952abcdae2f99cdd7aed0d3dbca7ee5fd3752d152b4361ff74a60b09e1f0d",
+ "scripts/token_calibration_fixture.json": "138b82a0c45fb667cee00c11f91fcc2c067676a16522b571605621035a57838e"
+ },
+ "results": {
+ "retrieval": {
+ "status": "ok",
+ "path": "deterministic_hash_fts",
+ "cases": 163,
+ "top1_accuracy": 1.0,
+ "recall_at_3": 1.0,
+ "precision_at_3": 0.947
+ },
+ "relevance_monotonicity": {
+ "status": "ok",
+ "cases": 160,
+ "pairs": 41,
+ "pairwise_concordance": 1.0,
+ "reordered_top1": 1.0
+ },
+ "semantic_rerank": {
+ "status": "not_measured",
+ "reason": "model2vec package/model asset was not provisioned for this snapshot"
+ },
+ "context_packing": {
+ "status": "ok",
+ "tasks": 10,
+ "checks": 30,
+ "citation_coverage": 1.0,
+ "mrr": 1.0,
+ "ndcg_at_k": 0.888,
+ "no_leak": 1.0,
+ "budget_adherence": 1.0
+ },
+ "session_replay": {
+ "status": "ok",
+ "sessions": 2,
+ "turns": 9,
+ "recall_at_3": 1.0,
+ "prefetch_hit_rate": 0.75,
+ "delta_token_savings": 0.285,
+ "no_resend_or_leak": 1.0
+ },
+ "adaptation": {
+ "status": "ok",
+ "seeded_memories": 7,
+ "derived_rules": 10,
+ "readiness_score": 100
+ },
+ "token_calibration": {
+ "status": "ok",
+ "mean_profile_mape": 0.015815,
+ "flat_estimator_mape": 0.074255,
+ "relative_improvement": 0.787018
+ }
+ },
+ "limitations": [
+ "The run predates the final review commit and records a dirty-tree delta from the baseline.",
+ "Only one repetition was run, so variance and confidence intervals are unavailable.",
+ "Wall-clock timing and controlled cold/warm cache state were not captured.",
+ "The fixtures are synthetic and do not establish real-user answer quality."
+ ,"The retrieval result measures the deterministic hash/FTS path, not Model2Vec semantic or hybrid retrieval."
+ ,"The token-calibration references are simulated rather than live vendor tokenizer counts."
+ ,"Raw per-case artifacts and the dirty working-tree diff were not retained."
+ ]
+}
diff --git a/examples/README.md b/examples/README.md
new file mode 100644
index 00000000..74dc9be4
--- /dev/null
+++ b/examples/README.md
@@ -0,0 +1,127 @@
+# Cortex Examples
+
+These examples use the dependency-free Python SDK against a running Cortex
+server. They are intentionally provider-neutral: Cortex supplies cited memory;
+your application decides which model or agent framework consumes it.
+
+If you have not run Cortex yet, start with the repository-level disposable
+demo:
+
+```bash
+make demo
+```
+
+That proves capture → retrieval → cited Ask without touching personal data.
+Return here when you want a persistent server and code you can modify.
+
+## Run Locally
+
+Terminal 1:
+
+```bash
+# Skip this line if `make demo` or `make setup` already created `.venv`.
+make setup
+CORTEX_AUTO_APPROVE_CAPTURES=1 make run
+```
+
+Terminal 2:
+
+```bash
+export CORTEX_API_KEY=dev-local-key
+
+.venv/bin/python examples/seed_demo.py
+.venv/bin/python examples/minimal_search.py "When does Project Atlas ship?"
+```
+
+`dev-local-key` is accepted only because `make run` starts a loopback-only
+development server with the explicit insecure-development flag.
+`seed_demo.py` refuses to write anywhere except a loopback origin and
+refuses non-development tokens. Automated isolated test harnesses that
+deliberately use a different token must opt in with
+`CORTEX_ALLOW_DEMO_SEED=1`. The seeder idempotently loads three synthetic
+records from
+[`fixtures/demo_captures.json`](fixtures/demo_captures.json). For the
+packaged app, copy a scoped `cxa_` REST token from Connections & Privacy for
+the read-only examples and never commit it; do not run the demo seeder against
+a packaged or personal vault. The fixture includes a release decision, a
+storage decision, and an explicit
+writing preference, all about or usable with the Project Atlas example task.
+
+## Choose an Example
+
+| Goal | Run | What you get |
+|---|---|---|
+| Add cited memory search | `.venv/bin/python examples/minimal_search.py` | A concise result and source; add `--json` for retrieval diagnostics |
+| Build a complete memory step | `.venv/bin/python examples/memory_workflow.py` | A factual Search/Ask followed by context for a work-shaped task |
+| Give roles different context | `.venv/bin/python examples/multi_agent_context.py` | Separate planner, researcher, and writer views of approved memory |
+| Discover callable tools | `.venv/bin/python examples/tool_catalog.py` | An OpenAI-compatible tool catalog without calling a model |
+
+Read the corresponding source:
+[`minimal_search.py`](minimal_search.py) ·
+[`memory_workflow.py`](memory_workflow.py) ·
+[`multi_agent_context.py`](multi_agent_context.py) ·
+[`tool_catalog.py`](tool_catalog.py).
+
+Every script accepts `CORTEX_BASE_URL` and `CORTEX_API_KEY`. The default base
+URL is `http://127.0.0.1:8766`; the query/task examples consistently default
+to Project Atlas so the checked-in fixture produces deterministic, cited
+output without extra arguments.
+
+`memory_workflow.py` deliberately separates a factual `--query` (for Search and
+Ask) from a work-shaped `--task` (for Context). For example:
+
+```bash
+.venv/bin/python examples/memory_workflow.py \
+ --query "Which database did we choose for Project Atlas?" \
+ --task "Draft the Project Atlas architecture section."
+```
+
+## Integration Pattern
+
+```text
+user task
+ │
+ ▼
+request a cited Cortex context pack
+ │
+ ├── no evidence → abstain or ask the user
+ │
+ └── cited evidence → pass the bounded pack to your model/agent
+ │
+ ▼
+ keep memory IDs/citations
+```
+
+For model-specific function-calling examples, see the
+[Python SDK guide](../sdk/python/README.md) and
+[TypeScript SDK guide](../sdk/typescript/README.md).
+
+## Why Search, Ask, and Context Can Differ
+
+| Surface | Optimizes for | Expected empty/abstain behavior |
+|---|---|---|
+| Search | Ranked evidence matching one query | Can return no hits when lexical/semantic evidence is weak |
+| Ask | A defensible answer with citations | Abstains when retrieved evidence cannot support an answer |
+| Context | A bounded, layer-balanced pack for doing a task | May include useful decisions, preferences, or procedures that do not directly answer the query |
+
+These are separate contracts, not three aliases for the same retrieval call. A
+task context can correctly contain cited background while Ask correctly
+abstains from making a specific unsupported claim.
+
+## Best Practices
+
+- Mint the narrowest token scopes the integration needs.
+- Request bounded context for the current task instead of dumping the vault.
+- Preserve memory IDs and source URLs when a result affects an answer.
+- Treat an explicit abstention as a successful safety outcome.
+- Keep the user's newest instruction above stored memory.
+- Use synthetic data in demos and tests.
+
+## Anti-Patterns
+
+- Do not read the SQLite file or vault behind the user's back.
+- Do not turn search results into factual claims after citations were dropped.
+- Do not use the development token outside the local development server.
+- Do not automatically approve high-risk connector content without explaining
+ the trust policy.
+- Do not retry an abstention with broader and broader private context.
diff --git a/examples/fixtures/demo_captures.json b/examples/fixtures/demo_captures.json
new file mode 100644
index 00000000..f6d23d49
--- /dev/null
+++ b/examples/fixtures/demo_captures.json
@@ -0,0 +1,26 @@
+[
+ {
+ "capture_id_override": "cap_demo_project_atlas_release",
+ "captured_at": "2026-07-01T12:00:00+00:00",
+ "content": "Project Atlas ships on Thursday after the rollback drill. The release owner is Mina.",
+ "source": "cortex-demo",
+ "source_url": "https://example.invalid/cortex-demo/project-atlas",
+ "title": "Project Atlas release decision"
+ },
+ {
+ "capture_id_override": "cap_demo_storage_decision",
+ "captured_at": "2026-07-02T12:00:00+00:00",
+ "content": "We decided to keep the local vault as the source of truth and treat the SQLite search index as rebuildable.",
+ "source": "cortex-demo",
+ "source_url": "https://example.invalid/cortex-demo/storage",
+ "title": "Storage architecture decision"
+ },
+ {
+ "capture_id_override": "cap_demo_writer_preference",
+ "captured_at": "2026-07-03T12:00:00+00:00",
+ "content": "I prefer launch updates that use short paragraphs, lead with the decision, and include links to primary evidence.",
+ "source": "cortex-demo",
+ "source_url": "https://example.invalid/cortex-demo/writing",
+ "title": "Launch writing preference"
+ }
+]
diff --git a/examples/memory_workflow.py b/examples/memory_workflow.py
new file mode 100644
index 00000000..924e4ed1
--- /dev/null
+++ b/examples/memory_workflow.py
@@ -0,0 +1,51 @@
+#!/usr/bin/env python3
+"""Run the common search -> cited Ask -> bounded context workflow."""
+
+from __future__ import annotations
+
+import argparse
+import json
+import os
+from typing import Any
+
+from cortex_client import CortexClient
+
+
+def show(label: str, value: Any) -> None:
+ print(f"\n## {label}")
+ print(json.dumps(value, indent=2, sort_keys=True))
+
+
+def parse_args() -> argparse.Namespace:
+ parser = argparse.ArgumentParser(
+ description="Run factual retrieval and task-context assembly against Cortex."
+ )
+ parser.add_argument(
+ "--query",
+ default="When does Project Atlas ship?",
+ help="Factual question used for Search and cited Ask.",
+ )
+ parser.add_argument(
+ "--task",
+ default="Draft a concise Project Atlas release update.",
+ help="Work-shaped instruction used to assemble a bounded Context pack.",
+ )
+ return parser.parse_args()
+
+
+def main() -> None:
+ args = parse_args()
+ client = CortexClient(
+ base_url=os.environ.get("CORTEX_BASE_URL", "http://127.0.0.1:8766"),
+ token=os.environ.get("CORTEX_API_KEY", ""),
+ )
+ show("Search", client.search(args.query, top_k=5))
+ show("Cited answer or abstention", client.ask(args.query, top_k=8))
+ show(
+ "Task context",
+ client.context(args.task, intent="draft", token_budget=1200, surface="example"),
+ )
+
+
+if __name__ == "__main__":
+ main()
diff --git a/examples/minimal_search.py b/examples/minimal_search.py
new file mode 100644
index 00000000..7776e3ee
--- /dev/null
+++ b/examples/minimal_search.py
@@ -0,0 +1,73 @@
+#!/usr/bin/env python3
+"""Search local Cortex memory and print a short cited result."""
+
+from __future__ import annotations
+
+import argparse
+import json
+import os
+
+from cortex_client import CortexClient
+
+
+def _source_label(item: dict) -> str:
+ return str(
+ item.get("source_url")
+ or item.get("source")
+ or item.get("memory_id")
+ or item.get("id")
+ or "source unavailable"
+ ).strip()
+
+
+def main() -> int:
+ parser = argparse.ArgumentParser(
+ description="Search Cortex and print concise, source-preserving results."
+ )
+ parser.add_argument(
+ "query",
+ nargs="*",
+ help="Question or search terms. Defaults to the Project Atlas demo query.",
+ )
+ parser.add_argument(
+ "--json",
+ action="store_true",
+ help="Print the full retrieval payload and diagnostics as JSON.",
+ )
+ args = parser.parse_args()
+ query = " ".join(args.query).strip() or "When does Project Atlas ship?"
+ client = CortexClient(
+ base_url=os.environ.get("CORTEX_BASE_URL", "http://127.0.0.1:8766"),
+ token=os.environ.get("CORTEX_API_KEY", ""),
+ )
+ payload = client.search(query, top_k=5)
+ if args.json:
+ print(json.dumps(payload, indent=2, sort_keys=True))
+ return 0
+
+ results = payload.get("results", []) if isinstance(payload, dict) else []
+ print(f"Query: {query}")
+ if not results:
+ print("No cited memory matched. Approve relevant items or try a more specific query.")
+ return 0
+
+ print(f"Found {len(results)} cited result{'s' if len(results) != 1 else ''}:")
+ for index, item in enumerate(results, start=1):
+ content = " ".join(str(item.get("content") or item.get("summary") or "").split())
+ if len(content) > 220:
+ content = f"{content[:217].rstrip()}…"
+ print(f"\n[{index}] {content or '(no excerpt)'}")
+ print(f" Source: {_source_label(item)}")
+
+ retrieval = payload.get("retrieval") if isinstance(payload, dict) else None
+ if isinstance(retrieval, dict) and retrieval.get("degraded"):
+ print(
+ "\nNote: this development server is using its documented keyword-search "
+ "fallback. The packaged app bundles on-device semantic embeddings."
+ )
+ print("Run again with --json to inspect retrieval diagnostics.")
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/examples/multi_agent_context.py b/examples/multi_agent_context.py
new file mode 100644
index 00000000..9c5ab800
--- /dev/null
+++ b/examples/multi_agent_context.py
@@ -0,0 +1,39 @@
+#!/usr/bin/env python3
+"""Build separate bounded context for three collaborating agent roles."""
+
+from __future__ import annotations
+
+import json
+import os
+import sys
+
+from cortex_client import CortexClient
+
+
+ROLES = {
+ "planner": ("plan", 900),
+ "researcher": ("recall", 1400),
+ "writer": ("draft", 1100),
+}
+
+
+def main() -> None:
+ task = " ".join(sys.argv[1:]).strip() or "Prepare the Project Atlas release update"
+ client = CortexClient(
+ base_url=os.environ.get("CORTEX_BASE_URL", "http://127.0.0.1:8766"),
+ token=os.environ.get("CORTEX_API_KEY", ""),
+ )
+ packs = {
+ role: client.context(
+ f"{role}: {task}",
+ intent=intent,
+ token_budget=budget,
+ surface=f"example-{role}",
+ )
+ for role, (intent, budget) in ROLES.items()
+ }
+ print(json.dumps(packs, indent=2, sort_keys=True))
+
+
+if __name__ == "__main__":
+ main()
diff --git a/examples/seed_demo.py b/examples/seed_demo.py
new file mode 100644
index 00000000..69c4d756
--- /dev/null
+++ b/examples/seed_demo.py
@@ -0,0 +1,96 @@
+#!/usr/bin/env python3
+"""Idempotently seed synthetic data into a loopback-only Cortex dev server."""
+
+from __future__ import annotations
+
+import json
+import os
+from pathlib import Path
+import sys
+from urllib.error import HTTPError, URLError
+from urllib.parse import urlsplit
+from urllib.request import HTTPRedirectHandler, Request, build_opener
+
+
+FIXTURE = Path(__file__).with_name("fixtures") / "demo_captures.json"
+LOOPBACK_HOSTS = {"127.0.0.1", "::1", "localhost"}
+
+
+class _NoRedirect(HTTPRedirectHandler):
+ def redirect_request(self, req, fp, code, msg, headers, newurl): # type: ignore[override]
+ return None
+
+
+def _validated_base_url(raw: str) -> str:
+ value = raw.strip().rstrip("/")
+ parsed = urlsplit(value)
+ try:
+ _ = parsed.port
+ except ValueError as exc:
+ raise ValueError(f"invalid CORTEX_BASE_URL port: {exc}") from exc
+ if (
+ parsed.scheme not in {"http", "https"}
+ or parsed.hostname not in LOOPBACK_HOSTS
+ or parsed.username is not None
+ or parsed.password is not None
+ or parsed.path not in {"", "/"}
+ or parsed.query
+ or parsed.fragment
+ ):
+ raise ValueError(
+ "seed_demo.py only writes to an absolute loopback Cortex origin "
+ "(for example http://127.0.0.1:8766)"
+ )
+ return value
+
+
+def main() -> int:
+ try:
+ base_url = _validated_base_url(
+ os.environ.get("CORTEX_BASE_URL", "http://127.0.0.1:8766")
+ )
+ except ValueError as exc:
+ print(f"error: {exc}", file=sys.stderr)
+ return 2
+ token = os.environ.get("CORTEX_API_KEY", "dev-local-key").strip()
+ if token != "dev-local-key" and os.environ.get("CORTEX_ALLOW_DEMO_SEED") != "1":
+ print(
+ "error: seed_demo.py requires the canonical dev-local-key; "
+ "set CORTEX_ALLOW_DEMO_SEED=1 only for an intentional isolated test server",
+ file=sys.stderr,
+ )
+ return 2
+ captures = json.loads(FIXTURE.read_text(encoding="utf-8"))
+ opener = build_opener(_NoRedirect())
+
+ saved = 0
+ for capture in captures:
+ request = Request(
+ f"{base_url}/v1/captures",
+ data=json.dumps(capture).encode("utf-8"),
+ method="POST",
+ headers={
+ "Accept": "application/json",
+ "Authorization": f"Bearer {token}",
+ "Content-Type": "application/json",
+ },
+ )
+ try:
+ with opener.open(request, timeout=10) as response:
+ payload = json.loads(response.read().decode("utf-8"))
+ except HTTPError as exc:
+ detail = exc.read().decode("utf-8", "replace")
+ print(f"error: Cortex returned HTTP {exc.code}: {detail}", file=sys.stderr)
+ return 1
+ except URLError as exc:
+ print(f"error: could not reach Cortex at {base_url}: {exc.reason}", file=sys.stderr)
+ return 1
+ saved += 1
+ print(f"seeded {payload.get('capture_id', capture['capture_id_override'])}: {capture['title']}")
+
+ print(f"done: {saved} deterministic synthetic captures are ready")
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/examples/tool_catalog.py b/examples/tool_catalog.py
new file mode 100644
index 00000000..21efe037
--- /dev/null
+++ b/examples/tool_catalog.py
@@ -0,0 +1,29 @@
+#!/usr/bin/env python3
+"""Inspect the OpenAI-compatible Cortex tool catalog."""
+
+from __future__ import annotations
+
+import json
+import os
+
+from cortex_client import CortexClient
+
+
+def main() -> None:
+ client = CortexClient(
+ base_url=os.environ.get("CORTEX_BASE_URL", "http://127.0.0.1:8766"),
+ token=os.environ.get("CORTEX_API_KEY", ""),
+ )
+ tools = client.openai_tools()
+ summary = [
+ {
+ "name": tool["function"]["name"],
+ "description": tool["function"].get("description", ""),
+ }
+ for tool in tools
+ ]
+ print(json.dumps(summary, indent=2, sort_keys=True))
+
+
+if __name__ == "__main__":
+ main()
diff --git a/legacy/README.md b/legacy/README.md
new file mode 100644
index 00000000..acabb9c0
--- /dev/null
+++ b/legacy/README.md
@@ -0,0 +1,17 @@
+# Archived Prototype
+
+This directory preserves the original Cortex proof of concept for historical
+reference. It used Redis, Voyage embeddings, GitHub-backed storage, Streamlit,
+and a Python menu-bar capture app.
+
+It is **not** the current product, an installation path, or a supported
+extension API. Do not install `legacy/requirements.txt` to work on Cortex.
+
+Current work belongs in:
+
+- `backend/app/` for storage, retrieval, connectors, and HTTP/MCP behavior;
+- `macos/` for the desktop app;
+- `sdk/`, `packages/`, or `extension/` for integrations; and
+- `scripts/` for maintained developer and release tooling.
+
+See the root `README.md` and `CONTRIBUTING.md` for supported setup commands.
diff --git a/capture.py b/legacy/capture.py
similarity index 99%
rename from capture.py
rename to legacy/capture.py
index 0455fa36..e4e8817e 100644
--- a/capture.py
+++ b/legacy/capture.py
@@ -1,5 +1,5 @@
"""
-cortex/capture.py
+legacy/capture.py
-----------------
Global hotkey capture tool. Lives in your Mac menu bar.
diff --git a/github_store.py b/legacy/github_store.py
similarity index 99%
rename from github_store.py
rename to legacy/github_store.py
index a891ae96..a04b4d22 100644
--- a/github_store.py
+++ b/legacy/github_store.py
@@ -1,5 +1,5 @@
"""
-cortex/github_store.py
+legacy/github_store.py
----------------------
Push extracted context to GitHub repo via the REST API.
diff --git a/ingest.py b/legacy/ingest.py
similarity index 99%
rename from ingest.py
rename to legacy/ingest.py
index 744dbf47..522aa071 100644
--- a/ingest.py
+++ b/legacy/ingest.py
@@ -1,5 +1,5 @@
"""
-cortex/ingest.py
+legacy/ingest.py
----------------
Core extraction pipeline. Takes raw text from any source,
calls Claude to extract structured context, returns a dict.
diff --git a/instrumentation.py b/legacy/instrumentation.py
similarity index 98%
rename from instrumentation.py
rename to legacy/instrumentation.py
index c97fe50a..2c5429b9 100644
--- a/instrumentation.py
+++ b/legacy/instrumentation.py
@@ -1,5 +1,5 @@
"""
-cortex/instrumentation.py
+legacy/instrumentation.py
--------------------------
Centralized Arize AX tracing setup.
diff --git a/mcp_server.py b/legacy/mcp_server.py
similarity index 99%
rename from mcp_server.py
rename to legacy/mcp_server.py
index 1062549a..4fccef07 100644
--- a/mcp_server.py
+++ b/legacy/mcp_server.py
@@ -1,5 +1,5 @@
"""
-cortex/mcp_server.py
+legacy/mcp_server.py
--------------------
Cortex MCP server — exposes your second brain to Claude and any MCP-compatible AI.
diff --git a/redis_store.py b/legacy/redis_store.py
similarity index 99%
rename from redis_store.py
rename to legacy/redis_store.py
index c13627ae..98d76982 100644
--- a/redis_store.py
+++ b/legacy/redis_store.py
@@ -1,5 +1,5 @@
"""
-cortex/redis_store.py
+legacy/redis_store.py
---------------------
Embed context items and store in Redis Stack for semantic + metadata search.
diff --git a/requirements.txt b/legacy/requirements.txt
similarity index 68%
rename from requirements.txt
rename to legacy/requirements.txt
index add22c92..b00df40d 100644
--- a/requirements.txt
+++ b/legacy/requirements.txt
@@ -1,10 +1,11 @@
+# Archived prototype dependencies; not used by the current product.
anthropic>=0.40.0
mcp>=1.0.0
redis[hiredis]>=5.0.0
voyageai>=0.2.0
requests>=2.31.0
python-dotenv>=1.0.0
-rumps>=0.4.0
+rumps>=0.4.0; sys_platform == "darwin"
pynput>=1.7.0
pyperclip>=1.8.0
uvicorn>=0.20.0
diff --git a/ui.py b/legacy/ui.py
similarity index 99%
rename from ui.py
rename to legacy/ui.py
index a8b501ac..01ebf1a4 100644
--- a/ui.py
+++ b/legacy/ui.py
@@ -1,5 +1,5 @@
"""
-cortex/ui.py
+legacy/ui.py
------------
Streamlit chat UI for Cortex — your universal second brain.
diff --git a/macos/Sources/CortexApp.swift b/macos/Sources/CortexApp.swift
index 17c63f0e..15647945 100644
--- a/macos/Sources/CortexApp.swift
+++ b/macos/Sources/CortexApp.swift
@@ -5,7 +5,7 @@ import Foundation
import SwiftUI
import Carbon
import Security
-import UserNotifications
+@preconcurrency import UserNotifications
import Darwin
import UniformTypeIdentifiers
diff --git a/macos/Sources/QuickCapture.swift b/macos/Sources/QuickCapture.swift
index 79c4b22e..5f713306 100644
--- a/macos/Sources/QuickCapture.swift
+++ b/macos/Sources/QuickCapture.swift
@@ -3,7 +3,7 @@ import ApplicationServices
import Carbon
import CoreGraphics
import SwiftUI
-import Vision
+@preconcurrency import Vision
// MARK: - KeyCombo
diff --git a/macos/build.sh b/macos/build.sh
index 542ee6fd..e048859f 100755
--- a/macos/build.sh
+++ b/macos/build.sh
@@ -183,7 +183,13 @@ if [[ "$BUNDLE_PYTHON" != "0" && "$BUNDLE_PYTHON" != "false" && "$BUNDLE_PYTHON"
# not reject on these symbols) so outbound HTTPS connectors remain available.
DYNLOAD="$PY_STDLIB/lib-dynload"
strip_ext() { # $1 = glob under lib-dynload
- for f in "$DYNLOAD"/$1; do [[ -e "$f" ]] && rm -f "$f" && echo " pruned $(basename "$f")"; done
+ for f in "$DYNLOAD"/$1; do
+ if [[ -e "$f" ]]; then
+ rm -f "$f"
+ echo " pruned $(basename "$f")"
+ fi
+ done
+ return 0
}
echo "Pruning non-public-API C extensions (_tkinter always; _ssl in app-store mode)..."
strip_ext "_tkinter*.so"
@@ -264,8 +270,9 @@ if [[ "$BUNDLE_PYTHON" != "0" && "$BUNDLE_PYTHON" != "false" && "$BUNDLE_PYTHON"
"$PYTHON_FRAMEWORK_SOURCE/bin/python3.12" -m pip install \
--disable-pip-version-check \
--only-binary=:all: \
+ --require-hashes \
--target "$PY_RUNTIME_DEPS" \
- -r "$ROOT/../backend/runtime-requirements.txt"
+ -r "$ROOT/../backend/runtime-requirements.lock"
find "$PY_RUNTIME_DEPS" -type d -name "__pycache__" -prune -exec rm -rf {} +
find "$PY_RUNTIME_DEPS" -type f -name "*.pyc" -delete
# Notarization hygiene: joblib ships intentionally-truncated .gz test pickles that the
diff --git a/packages/obsidian-cortex-plugin/package-lock.json b/packages/obsidian-cortex-plugin/package-lock.json
index a4bf740e..e2b89c91 100644
--- a/packages/obsidian-cortex-plugin/package-lock.json
+++ b/packages/obsidian-cortex-plugin/package-lock.json
@@ -7,7 +7,7 @@
"": {
"name": "@doppl/cortex-obsidian-plugin",
"version": "0.1.0",
- "license": "UNLICENSED",
+ "license": "MIT",
"devDependencies": {
"@types/node": "^24.0.10",
"esbuild": "^0.28.1",
diff --git a/packages/obsidian-cortex-plugin/package.json b/packages/obsidian-cortex-plugin/package.json
index 720de657..cc510134 100644
--- a/packages/obsidian-cortex-plugin/package.json
+++ b/packages/obsidian-cortex-plugin/package.json
@@ -3,7 +3,7 @@
"version": "0.1.0",
"private": true,
"description": "Obsidian plugin package for syncing a local vault into Cortex memory.",
- "license": "UNLICENSED",
+ "license": "MIT",
"type": "module",
"main": "main.js",
"scripts": {
diff --git a/packages/openclaw-cortex-context/README.md b/packages/openclaw-cortex-context/README.md
index 70753779..61ba6d27 100644
--- a/packages/openclaw-cortex-context/README.md
+++ b/packages/openclaw-cortex-context/README.md
@@ -1,4 +1,4 @@
-# `@cortex/openclaw-context`
+# `@doppl-tech/openclaw-context`
Reference OpenClaw `contextEngine` adapter for [Cortex](../../README.md). It has two modes:
@@ -119,7 +119,7 @@ Bundle-mode context also exposes the verified signer id, source-tenant SHA-256 b
The independent TypeScript protocol verifier is also exported:
```ts
-import { verifyPortableMemoryBundle } from "@cortex/openclaw-context/portable";
+import { verifyPortableMemoryBundle } from "@doppl-tech/openclaw-context/portable";
const result = verifyPortableMemoryBundle(bundle, expectedSigningKeyId);
if (!result.verified) throw new Error(result.error ?? "verification failed");
diff --git a/packages/openclaw-cortex-context/package-lock.json b/packages/openclaw-cortex-context/package-lock.json
index 9686db47..d4ce816a 100644
--- a/packages/openclaw-cortex-context/package-lock.json
+++ b/packages/openclaw-cortex-context/package-lock.json
@@ -1,11 +1,11 @@
{
- "name": "@cortex/openclaw-context",
+ "name": "@doppl-tech/openclaw-context",
"version": "0.1.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
- "name": "@cortex/openclaw-context",
+ "name": "@doppl-tech/openclaw-context",
"version": "0.1.0",
"license": "MIT",
"devDependencies": {
diff --git a/packages/openclaw-cortex-context/package.json b/packages/openclaw-cortex-context/package.json
index cfc6cdff..7bb4ac7e 100644
--- a/packages/openclaw-cortex-context/package.json
+++ b/packages/openclaw-cortex-context/package.json
@@ -1,5 +1,5 @@
{
- "name": "@cortex/openclaw-context",
+ "name": "@doppl-tech/openclaw-context",
"version": "0.1.0",
"description": "OpenClaw contextEngine adapter for live Cortex and signed portable-memory bundles.",
"type": "module",
@@ -41,9 +41,12 @@
},
"repository": {
"type": "git",
- "url": "https://github.com/doppl-tech/cortex-app.git",
+ "url": "https://github.com/trace-cortex/cortex-app.git",
"directory": "packages/openclaw-cortex-context"
},
+ "publishConfig": {
+ "access": "public"
+ },
"peerDependencies": {
"openclaw": ">=2026.6.11"
},
diff --git a/requirements-dev.in b/requirements-dev.in
new file mode 100644
index 00000000..b768eac8
--- /dev/null
+++ b/requirements-dev.in
@@ -0,0 +1,6 @@
+# Direct dependencies for the Python 3.12 contributor and CI environment.
+#
+# Regenerate requirements-dev.lock with:
+# make lock-python
+-r backend/requirements.txt
+pytest>=7.0
diff --git a/requirements-dev.lock b/requirements-dev.lock
new file mode 100644
index 00000000..683c5c84
--- /dev/null
+++ b/requirements-dev.lock
@@ -0,0 +1,551 @@
+#
+# This file is autogenerated by pip-compile with Python 3.12
+# by the following command:
+#
+# make lock-python
+#
+annotated-doc==0.0.5 \
+ --hash=sha256:117bac03a25ede5df5440e855b32d556049ca169ead221505badf432fed4b101 \
+ --hash=sha256:c7e58ce09192557605d8bbd92836d7e1d520ac9580096042c0bfd197efacf1bb
+ # via fastapi
+annotated-types==0.8.0 \
+ --hash=sha256:13b2beaad985e05e2d6407ee4c4f35590b11f8d693a258a561055cac8f64cab7 \
+ --hash=sha256:f072f4d804ea359e4eaf198b1af7a8b0943881a87f31bb764f8bf219bb9419e0
+ # via pydantic
+anthropic==0.120.2 \
+ --hash=sha256:0f0bc2b381dc0eb41c8d886b815d79c2041cd2374f83aed36f574b6dc9c579c1 \
+ --hash=sha256:9722efc10c27a30a69f5338ddacdb35bc6a64297a4e4ba729bf83af873d5fb3a
+ # via -r backend/requirements.txt
+anyio==4.14.2 \
+ --hash=sha256:9f505dda5ac9f0c8309b5e8bd445a8c2bf7246f3ce950121e45ea15bc41d1494 \
+ --hash=sha256:cfa139f3ed1a23ee8f88a145ddb5ac7605b8bbfd8592baacd7ce3d8bb4313c7f
+ # via
+ # anthropic
+ # httpx
+ # starlette
+argon2-cffi==25.1.0 \
+ --hash=sha256:694ae5cc8a42f4c4e2bf2ca0e64e51e23a040c6a517a85074683d3959e1346c1 \
+ --hash=sha256:fdc8b074db390fccb6eb4a3604ae7231f219aa669a2652e0f20e16ba513d5741
+ # via -r backend/requirements.txt
+argon2-cffi-bindings==25.1.0 \
+ --hash=sha256:1db89609c06afa1a214a69a462ea741cf735b29a57530478c06eb81dd403de99 \
+ --hash=sha256:1e021e87faa76ae0d413b619fe2b65ab9a037f24c60a1e6cc43457ae20de6dc6 \
+ --hash=sha256:21378b40e1b8d1655dd5310c84a40fc19a9aa5e6366e835ceb8576bf0fea716d \
+ --hash=sha256:2630b6240b495dfab90aebe159ff784d08ea999aa4b0d17efa734055a07d2f44 \
+ --hash=sha256:3c6702abc36bf3ccba3f802b799505def420a1b7039862014a65db3205967f5a \
+ --hash=sha256:3d3f05610594151994ca9ccb3c771115bdb4daef161976a266f0dd8aa9996b8f \
+ --hash=sha256:473bcb5f82924b1becbb637b63303ec8d10e84c8d241119419897a26116515d2 \
+ --hash=sha256:5acb4e41090d53f17ca1110c3427f0a130f944b896fc8c83973219c97f57b690 \
+ --hash=sha256:5d588dec224e2a83edbdc785a5e6f3c6cd736f46bfd4b441bbb5aa1f5085e584 \
+ --hash=sha256:6dca33a9859abf613e22733131fc9194091c1fa7cb3e131c143056b4856aa47e \
+ --hash=sha256:7aef0c91e2c0fbca6fc68e7555aa60ef7008a739cbe045541e438373bc54d2b0 \
+ --hash=sha256:84a461d4d84ae1295871329b346a97f68eade8c53b6ed9a7ca2d7467f3c8ff6f \
+ --hash=sha256:87c33a52407e4c41f3b70a9c2d3f6056d88b10dad7695be708c5021673f55623 \
+ --hash=sha256:8b8efee945193e667a396cbc7b4fb7d357297d6234d30a489905d96caabde56b \
+ --hash=sha256:a1c70058c6ab1e352304ac7e3b52554daadacd8d453c1752e547c76e9c99ac44 \
+ --hash=sha256:a98cd7d17e9f7ce244c0803cad3c23a7d379c301ba618a5fa76a67d116618b98 \
+ --hash=sha256:aecba1723ae35330a008418a91ea6cfcedf6d31e5fbaa056a166462ff066d500 \
+ --hash=sha256:b0fdbcf513833809c882823f98dc2f931cf659d9a1429616ac3adebb49f5db94 \
+ --hash=sha256:b55aec3565b65f56455eebc9b9f34130440404f27fe21c3b375bf1ea4d8fbae6 \
+ --hash=sha256:b957f3e6ea4d55d820e40ff76f450952807013d361a65d7f28acc0acbf29229d \
+ --hash=sha256:ba92837e4a9aa6a508c8d2d7883ed5a8f6c308c89a4790e1e447a220deb79a85 \
+ --hash=sha256:c4f9665de60b1b0e99bcd6be4f17d90339698ce954cfd8d9cf4f91c995165a92 \
+ --hash=sha256:c87b72589133f0346a1cb8d5ecca4b933e3c9b64656c9d175270a000e73b288d \
+ --hash=sha256:d3e924cfc503018a714f94a49a149fdc0b644eaead5d1f089330399134fa028a \
+ --hash=sha256:da0c79c23a63723aa5d782250fbf51b768abca630285262fb5144ba5ae01e520 \
+ --hash=sha256:e2fd3bfbff3c5d74fef31a722f729bf93500910db650c925c2d6ef879a7e51cb
+ # via argon2-cffi
+certifi==2026.7.22 \
+ --hash=sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775 \
+ --hash=sha256:741e2c3b351ddf169a738da9f2c048608ff7f2c5cc02f1ebc6b118bb090d5d55
+ # via
+ # httpcore
+ # httpx
+cffi==2.1.0 \
+ --hash=sha256:02cb7ff33ded4f1532476731f89ede53e2e488a8e6205515a82144246ffa7dcc \
+ --hash=sha256:03e9810d18c646077e501f661b682fbf5dee4676048527ca3cffe66faa9960dd \
+ --hash=sha256:0520e1f4c35f44e209cbbb421b67eec42e6a157f59444dfb6058874ff3610e5d \
+ --hash=sha256:0582a58f3051372229ca8e7f5f589f9e5632678208d8636fea3676711fdf7fe5 \
+ --hash=sha256:0611e7ebf90573a535ebdc33ae9da222d037853983e13359f580fab781ca017f \
+ --hash=sha256:0a42c688d19fca6e095a53c6a6e2295a5b050a8b289f109adab02a9e61a25de6 \
+ --hash=sha256:0a96b74cda968eebbad56d973efe5098974f0a9fb323865bf99ea1fd24e3e64c \
+ --hash=sha256:10537b1df4967ca26d21e5072d7d54188354483b91dc75058968d3f0cf13fbda \
+ --hash=sha256:11b3fb55f4f8ad92274ed26705f65d8f91457de71f5380061eb6d125a768fecd \
+ --hash=sha256:15faec4adfff450819f3aee0e2e02c812de6edb88203aa58807955db2003472a \
+ --hash=sha256:164bff1657b2a74f0b6d54e11c9b375bc97b931f2ca9c43fcf875838da1570dd \
+ --hash=sha256:1854b724d00f6654c742097d5387569021be12d3a0f770eae1df8f8acfcc6acd \
+ --hash=sha256:19c54ac121cad98450b4896fa9a43ee0180d57bc4bc911a33db6cab1efab6cd3 \
+ --hash=sha256:1b96bfe2c4bd825681b7d311ad6d9b7280a091f43e8f63da5729638083cd3bfb \
+ --hash=sha256:1e9f50d192a3e525b15a75ab5114e442d83d657b7ec29182a991bc9a88fd3a66 \
+ --hash=sha256:1ff3456eab0d889592d1936d6125bbfbc7ae4d3354a700f8bd80450a66445d4d \
+ --hash=sha256:2282cd5e38aa8accd03e99d1256af8411c84cdbee6a89d841b563fdbd1f3e50f \
+ --hash=sha256:276f20fffd7b396e12516ba8edf9509210ac248cbbc5acbc39cd512f9f59ebe6 \
+ --hash=sha256:2b71d409cccee78310ab5dec549aed052aaea483346e282c7b02362596e01bb0 \
+ --hash=sha256:2e9dabb9abcb7ad15938c7196ad5c1718a4e6d33cc79b4c0209bdb64c4a54a5c \
+ --hash=sha256:30b65779d598c370374fefabf138d456fd6f3216bfa7bedfab1ba82025b0cd93 \
+ --hash=sha256:33eb1ad83ebe8f313e0df035c406227d55a79456704a863fad9842136af5ad7d \
+ --hash=sha256:35aaea0c7ee0e58a5cd8c2fd1a48fdf7ece0d2699b7ecdda08194e9ce5dd9b3d \
+ --hash=sha256:3681e031db29958a7502f5c0c9d6bbc4c36cb20f7b104086fa642d1799631ff8 \
+ --hash=sha256:379de10ce1ba048b1448599d1b37b24caee16309d1ac98d3982fc997f768700b \
+ --hash=sha256:37f525a7e7e50c017fdebe58b787be310ad59357ae43a053943a6e1a6c526001 \
+ --hash=sha256:3b926723c13eba9f81d2ef3820d63aeceec3b2d4639906047bf675cb8a7a500d \
+ --hash=sha256:3d7f118b5adbfdfead90c25822690b02bc8074fba949bb7858bec4ebd55adb43 \
+ --hash=sha256:46b1c8db8f6122420f32d02fffb924c2fe9bc772d228c7c711748fff56aabb2b \
+ --hash=sha256:47ff3a8bfd8cb9da1af7524b965127095055654c177fcfc7578debcb015eecd0 \
+ --hash=sha256:4d433a51f1870e43a13b6732f92aaf540ff77c2015097c78556f75a2d6c030e0 \
+ --hash=sha256:4f26194e3d95e06501b942642855aed4f953d55e95d7d01b7c4483db3ecff458 \
+ --hash=sha256:510aeeeac94811b138077451da1fb18b308a5feab47dd2b603af55804155e1c8 \
+ --hash=sha256:5972433ad71a9e46516584ef60a0fda12d9dc459938d1539c3ddecf9bdc1368d \
+ --hash=sha256:5ecbd0499275d57506d397eebe1981cee87b47fcd9ef5c22cab7ed7644a39a94 \
+ --hash=sha256:6274dcb2d15cef48daa73ed1be5a40d501d74dccd0cd6db364776d12cb6ba022 \
+ --hash=sha256:63960549e4f8dc41e31accb97b975abaecfc44c03e396c093a6436763c2ea7db \
+ --hash=sha256:64c753a0f87a256020004f37a1c8c02c480e725f910f0b2a0f3f07debd1b2479 \
+ --hash=sha256:6af371f3767faeffc6ac1ef57cdfd25844403e9d3f476c5537caee499de96376 \
+ --hash=sha256:6ca4919c6e4f89aa99c42510b42cf54596892c00b3f9077f6bdd1505e24b9c8d \
+ --hash=sha256:6d194185eabd279f1c05ebe3504265ddfc5ad2b58d0714f7db9f01da592e9eb6 \
+ --hash=sha256:702c436735fbe99d59ada02a1f65cfc0d31c0ee8b7290912f8fbc5cd1e4b16c3 \
+ --hash=sha256:716ff8ec22f20b4d988b12884086bcef0fc99737043e503f7a3935a6be99b1ea \
+ --hash=sha256:762f99479dcb369f60ab9017ad4ab97a36a1dd7c1ee5a3b15db0f4b8659120cd \
+ --hash=sha256:7762faa47e8ff7eb80bd261d9a7d8eea2d8baa69de5e95b70c1f338bbe712f02 \
+ --hash=sha256:78474632761faa0fb96f30b1c928c84ebcf68713cbb80d15bab09dfe61640fde \
+ --hash=sha256:799416bae98336e400981ff6e532d67d5c709cfb30afb79865a1315f94b0e224 \
+ --hash=sha256:7d034dcffa09e9a46c93fa3a3be402096cb5354ac6e41ab8e5cc9cd8b642ad76 \
+ --hash=sha256:7d28dff1db6764108bc30788d85d61c876beff416d9a49cb9dd7c5a9f34f5804 \
+ --hash=sha256:7d3538f9c0e50670f4deb93dbb696576e60590369cae2faf7de681e597a8a1f1 \
+ --hash=sha256:7d5980a3433d4b71a5e120f9dd551403d7824e31e2e67124fe2769c404c06913 \
+ --hash=sha256:7ea6b3e2c4250ff1de21c630fe72d0f63eb95c2c32ffbf64a358cf4a8836d714 \
+ --hash=sha256:86cf8755a791f72c85dc287128cc62d4f24d392e3f1e15837245623f4a33cccc \
+ --hash=sha256:88023dfe18799507b73f1dbb0d14326a17465de1bc9c9c7655c22845e9ddc3a2 \
+ --hash=sha256:89095c1968b4ba8285840e131bf2891b09ae137fe2146905acae0354fbce1b5e \
+ --hash=sha256:8d35c139744adb3e727cd51b1a18324bbe44b8bd41bf8322bca4d41289f48eda \
+ --hash=sha256:8e74a6135550c4748af665b1b1118b6aab33b1fc6a16f9aff630af107c3b4512 \
+ --hash=sha256:8f9ec95b8a043d3dfbc74d9abc6f7baf524dd27a8dc160b0a32ff9cdab650c28 \
+ --hash=sha256:90bec57cf82089383bd06a605b3eb8daebf7e5a668520beaf6e327a83a947699 \
+ --hash=sha256:95f2954c2c9473d892eca6e0409f3568b37ab62a8eedb122461f73cc273476e3 \
+ --hash=sha256:961be50688f7fba2fa65f63712d3b9b341a22311f5253460ce933f52f0de1c8c \
+ --hash=sha256:98fff996e983a36d3aa2eca83af40c5821202e7e6f32d13ae94e3d2286f10cfe \
+ --hash=sha256:9b8f0f26ca4e7513c534d351eca551947d053fac438f2a04ac96d882909b0d3a \
+ --hash=sha256:9d72af0cf10a76a600a9690078fe31c63b9588c8e86bf9fd353f713c84b5db0f \
+ --hash=sha256:9d8272c0e483b024e1b9ad029821470ed8ec65631dbd90217469da0e7cd89f1c \
+ --hash=sha256:a016194dbe13d14ee9556e734b772d8d67b947092b268d757fd4290e3ba2dfc2 \
+ --hash=sha256:a5781494d4d400a3f47f8f1da94b324f6e6b440a53387774002890a2a2f4b50f \
+ --hash=sha256:a95b05f9baf29b91171b3a8bd2020b028835243e7b0ff6bb23e2a3c228518b1b \
+ --hash=sha256:aa7a1b53a2a4452ada2d1b5dade9960b2522f1e61293a811a077439e39029565 \
+ --hash=sha256:ac0f1a2d0cfa7eea3f2aaf006ab6e70e8feeb16b75d65b7e5939982ca2f11056 \
+ --hash=sha256:af5e2915d41fe6c961694d7bfdc8562942638200f3ce2765dfb8b745cf997629 \
+ --hash=sha256:b6422532152adf4e59b110cb2808cee7a033800952f5c036b4af047ee43199e7 \
+ --hash=sha256:b65f590ef2a44640f9a05dbb548a429b4ade77913ce683ac8b1480777658a6c0 \
+ --hash=sha256:ba00f661f8ba35d075c937174e27c2c421cec3942fd2e0ea3e66996757c0fdd9 \
+ --hash=sha256:bccbbb5ee76a61f9d99b5bf3846a51d7fca4b6a732fe46f89295610edaf41853 \
+ --hash=sha256:bf01d8c84cbea96b944c73b22182e6c7c432b3475632b8111dbfdc95ddad6e13 \
+ --hash=sha256:bf5c6cf48238b0eb4c086978c492ad1cbc22373fc5b2d7353b3a598ce6db887a \
+ --hash=sha256:c16914df9fb7f500e440e6875fa23ff5e0b31db01fa9c06af98d59a91f0dc2e4 \
+ --hash=sha256:c351efb95e832a853a29361675f33a7ce53de1a109cd73fd47af0712213aa4ce \
+ --hash=sha256:c4165821e131d6d4ca444347c2b694e2311bcfa3fe5a861cc72968f28867beac \
+ --hash=sha256:c5f5df567f6eb216de69be06ce55c8b714090fae02b18a3b40da8163b8c5fa9c \
+ --hash=sha256:c941bb58d5a6e1c3892d86e42927ed6c180302f07e6d395d08c416e594b98b46 \
+ --hash=sha256:c97f080ea627e2863524c5af3836e2270b5f5dfff1f104392b959f8df0c5d384 \
+ --hash=sha256:cb96698e3c7413d906ce83f8ffd245ec1bd94707541f299d0ce4d6b0193e982b \
+ --hash=sha256:cbb7640ce37159548d2147b5b8c241f962143d4c71231431820783f4dc78f210 \
+ --hash=sha256:cdf2448aab5f661c9315308ec8b93f4e8a1a67a3c733f8631067a2b67d5913dc \
+ --hash=sha256:d2117334c3af3bdcb9a88522b844a2bdb5efdc4f71c6c822df55486ae1c3347a \
+ --hash=sha256:d53d10f7da99ae46f7373b9150393e9c5eab9b224909982b43832668de4779f5 \
+ --hash=sha256:d9fafc5aa2e2a39aaf7f8cc0c1f044a9b07fca12e558dca53a3cc5c654ad67a7 \
+ --hash=sha256:db3eb7d46527159a878ec3460e9d40615bc25ba337d477db681aea6e4f05c5d2 \
+ --hash=sha256:dbf7c7a88e2bac086f06d14577332760bdeecc42bdec8ac4077f6260557d9326 \
+ --hash=sha256:df2b82571a1b30f58a87bf4e5a9e78d2b1eff6c6ce8fd3aa3757221f93f0863f \
+ --hash=sha256:df92f2aba50eb4d96718b68ef76f2e57a57b54f2fa62333496d16c6d585a85ca \
+ --hash=sha256:eb4e8997a49aa2c08a3e43c9045d224448b8941d88e7ac163c7d383e560cbf98 \
+ --hash=sha256:efc1cdd798b1aaf39b4610bba7aad28c9bea9b910f25c784ccf9ec1fa719d1f9 \
+ --hash=sha256:f146d154428a2523f9cc7936c02353c2459b8f6cf07d3cd1ee1c0a611109c5d5 \
+ --hash=sha256:f5bce581e6b8c235e566a14768a943b172ada3ed73537bb0c0be1edee312d4e7 \
+ --hash=sha256:f9912624a0c0b834b7520d7769b3644453aabc0a7e1c839da7359f050750e9bc \
+ --hash=sha256:fb62edb5bb52cca65fab91a63afa7561607120d26090a7e8fda6fb9f064726da \
+ --hash=sha256:ff067a8d8d880e7809e4ac88eb009bb848870115317b306666502ccad30b147f
+ # via
+ # argon2-cffi-bindings
+ # cryptography
+click==8.4.2 \
+ --hash=sha256:9a6cea6e60b17ebe0a44c5cc636d94f09bd66142c1cd7d8b4cd731c4917a15f6 \
+ --hash=sha256:e6f9f66136c816745b9d65817da91d61d957fb16e02e4dcd0552553c5a197b76
+ # via uvicorn
+cryptography==49.0.0 \
+ --hash=sha256:026ac7423e6fa66872d3bf889be5974507da3944f866f704fa200eadacd00001 \
+ --hash=sha256:07cab27cc7b7e0fd28e5e26bb9eeedde5c135c868b46de4a27845abe94af6122 \
+ --hash=sha256:084ef1af862eb07ec46d25f68689f2102a9fc0e05ce7b80f14f5fe51e4eef0f6 \
+ --hash=sha256:0b82e28ee398a386f0807bba7884d30f25218855690f45115831bcce5d90822c \
+ --hash=sha256:0e959b578856a3924bc0cbb710fc12c387b9412a951389f3ca61704a9e25f325 \
+ --hash=sha256:0f21641cf4b30fca7aee061ced0ec7ad7b073518088b7c9969a297c0ae796c69 \
+ --hash=sha256:196ecd6a36e4e9aa10270393bb98d8df88fccee0bf1e5128b91ae4eb4375896d \
+ --hash=sha256:2400ef9c9e2299a25614eb1dea3db54a69b1349efd043bfac9c67630d136df36 \
+ --hash=sha256:28d8b15e6275f12c8a207dc309dfa957903c927d08d0cc937ee3f63f200693cc \
+ --hash=sha256:2afe9051da7ae7bd5905da5a949280c7d2bb75682e188f650a9d0f2756b834c6 \
+ --hash=sha256:2eda353d8a27bcbcaa4cbed18994a74ab4d19a2ca897db188ea269ab9b71419b \
+ --hash=sha256:32703d93296f5c1f4b53349ad3a250c2cae0fdecd3a3dd5d47e616d8d616af27 \
+ --hash=sha256:33cd0565932807baddb67b96dbee92f2c374b5c89dee09fd74079aeb8c8dba61 \
+ --hash=sha256:35b151772baff2c74cba7fa290ceaff4c3b11c0c881eb93eb5dbc05a7cfbba18 \
+ --hash=sha256:36d1709f992593689b45bda411498d62c6e365f2ca00b84657d4dadd24de16db \
+ --hash=sha256:42b0684e0e40cf26122427802486f6d93aea593612603a94fbf260c7eb1e9c1b \
+ --hash=sha256:4ae387c9cb68ea569ca17e490d66d8142b81c3cc814bf179974b7d146e490bbb \
+ --hash=sha256:53ecee2e23f7169b6117e99fc8a944e5e50f79e69758a83b52a00cb98ab2b2d2 \
+ --hash=sha256:66ec79c3904820572d7e987abdf304281f141d37ad9a489b8e97066e7b9b6459 \
+ --hash=sha256:67e1d20ad9ef3a563c59ef22e7a8a0b8210bd26604369ea4a30a7c66aefe504e \
+ --hash=sha256:6f2debedf9ca60cf1d5bd466475638af5130f89965605cd818484d19987d3a21 \
+ --hash=sha256:6fc361c34fb6aac015ce19435876635e5c6d21db31998b0920f675f131e043b8 \
+ --hash=sha256:73a205dce83953d131a4aa1e0fd917a2fd1c5b1eef251e9d7152efefcbf5caf7 \
+ --hash=sha256:7abcee80084cda3f7691f3eb1ce480d8df49cec637b429aa35986c1de71738aa \
+ --hash=sha256:8c25ceb16df5b9435f3f6a9829204985b0e0cbee3b48aacd432c7d2c850b44d9 \
+ --hash=sha256:966fe0e9c67490071f14c0d2b1cb2dfb3023c5ce39457343931415f08382f2db \
+ --hash=sha256:9e82dcc8e56052715fb18b2429e3bca4823b1629136a2084fc45a9a5cecb9b64 \
+ --hash=sha256:b20133d204d2bb56ba047642199603876c872026ca53e79c35b83772ab2cc505 \
+ --hash=sha256:b39efa323140595abd3ecca8529d321ae50f55f3aa3ba9cc81ea56a6011953d5 \
+ --hash=sha256:b47db11c2c3525083296069b98ac5221907455e989ae0c2e3008bde851921615 \
+ --hash=sha256:b87e65d263b3e5d3bb92a57e2a6638e2f31110fa7aa890c7b2dbba42248d0a3f \
+ --hash=sha256:b970c6da94d5bb18629db453d14f2a1300f6bf59b61e9b82377931ef95504866 \
+ --hash=sha256:be9fcb48a55f023493482827d4f459bd263cc20efde64f204b97c123201850c6 \
+ --hash=sha256:c2bc30226390d60ea19d9f82b19db005fe0452154a23c1c410c12ea801e43561 \
+ --hash=sha256:c83782480a4a9da4d0feb51950131ba32e12e70813848b3343f6e18c28a66838 \
+ --hash=sha256:cbc77da8c523d5abd028635ba850a6966fcee2c82e2bf65a41d1d8afe0f98be9 \
+ --hash=sha256:ccac2bfebc306b862133e3bb71f3f6ee8bb525240089b2d952e4144b3a6d5da7 \
+ --hash=sha256:d0527ce944105f257f605a827d6ebead966c752038b6e8656abb9c5edee6fc68 \
+ --hash=sha256:d8ecde755e2e91bf773fc94e8c9d730cd7f2007004cb492263a794ec3899a1c8 \
+ --hash=sha256:e3fb64c420688e5319ae25113a354015abbd8dffbfbc41781a1ea66fc7622ac3 \
+ --hash=sha256:e5dfc1e64de5677cec922ffa8da89c546d0415bf6efdf081842e5d44c84e1f0e \
+ --hash=sha256:ec5e529fb80935c94fe7b729f9972b50e351a0e6b50aa294fd5cabb109fcc29a \
+ --hash=sha256:f37d847238971164fdbc68ade6f6574aecc9c0af714190e2083429ff68f4ce9d \
+ --hash=sha256:f78ff2c9ed8dc2d036b0f4d640e22522213d047c1b14e61205a7e55c80a494d4 \
+ --hash=sha256:f89660a348f4f78a92366240a61404e337586ef7f5909a2fef59ca88ef505493 \
+ --hash=sha256:fc1e275c2f1d97b1a6450b8b0ea3ebfa6e087a611c2b26cb2404d48588abab7b
+ # via -r backend/requirements.txt
+distro==1.9.0 \
+ --hash=sha256:2fa77c6fd8940f116ee1d6b94a2f90b13b5ea8d019b98bc8bafdcabcdd9bdbed \
+ --hash=sha256:7bffd925d65168f85027d8da9af6bddab658135b840670a223589bc0c8ef02b2
+ # via anthropic
+docstring-parser==0.18.0 \
+ --hash=sha256:292510982205c12b1248696f44959db3cdd1740237a968ea1e2e7a900eeb2015 \
+ --hash=sha256:b3fcbed555c47d8479be0796ef7e19c2670d428d72e96da63f3a40122860374b
+ # via anthropic
+fastapi==0.141.1 \
+ --hash=sha256:bfb91aa2d334c61cb35ba9a116fc123b3d3df31640b801cf57a7a78ec3f603b3 \
+ --hash=sha256:e8822fc40db1e1858054d7a949a888695bc9bdce70139178e33bd2871a453ca1
+ # via -r backend/requirements.txt
+h11==0.16.0 \
+ --hash=sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1 \
+ --hash=sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86
+ # via
+ # httpcore
+ # uvicorn
+httpcore==1.0.9 \
+ --hash=sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55 \
+ --hash=sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8
+ # via httpx
+httpx==0.28.1 \
+ --hash=sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc \
+ --hash=sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad
+ # via anthropic
+idna==3.18 \
+ --hash=sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2 \
+ --hash=sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848
+ # via
+ # anyio
+ # httpx
+iniconfig==2.3.0 \
+ --hash=sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730 \
+ --hash=sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12
+ # via pytest
+jiter==0.16.0 \
+ --hash=sha256:0542a7189c26920778658fc8fcf2af8bae05bae9924577f71804acef37996536 \
+ --hash=sha256:0758ab7747a984797cf048e8eedea1d8ef39d7994b25611daf5b48fc903e8873 \
+ --hash=sha256:0df91907609837f33341b8e6fe73b95991fdaa57caf1a0fbd343dffe826f386f \
+ --hash=sha256:0e2e9efbe042210df657bade597f66d6d75723e3d8f45a12ea6d8167ff8bbce3 \
+ --hash=sha256:0fa25b09b13075c46f5bc174f2690525a925a4fc2f7c82969a2bbabff22386ce \
+ --hash=sha256:10438939205546132189c8e74a2d536a707841f3a25cd7c74ee91fe503407a26 \
+ --hash=sha256:12288303c9844e61e1651d02a9a6f6633e47d39f897d6991d1427161ce6b746e \
+ --hash=sha256:17ca7fae79f6d99cd9a042b75f917eaada7b895cfc7dd2ee3a16089dcaec7a85 \
+ --hash=sha256:1878349266f8ee36ecb1375cc5ba2f115f35fd9f0a1a4119e725e379126647f7 \
+ --hash=sha256:1a0f05e229edb29e68cdd0ccb83cea13b64263416120cf943767a6fd72e6787f \
+ --hash=sha256:2577196f4474ef3fc4779a088a23b0897bbf86f9ea3679c372d45b8383b43207 \
+ --hash=sha256:26798522707abb47d767db536e4148ceac1b14446bf028ee85e579a2e043cfe5 \
+ --hash=sha256:26d122613ada2b708eb714695446f40fce5bdf2edb4b02116dec62faa62dfab3 \
+ --hash=sha256:2a0d46ef67cc58d906a6132dd3040ca70ae4f0b0d7c9c052fe432c658a69b3f6 \
+ --hash=sha256:2a8e9e39cf083016137aa5cadafe3188adc2ba6ba1fbf1e5d18889ad3e9ad056 \
+ --hash=sha256:2c842cbf374a8daf50b2c04212995bee34ca2ac2cdc29a901b4cdb072c9c4131 \
+ --hash=sha256:2ed5738ae4af18271a51a528b8811b0cbfa4a1858de9d83359e4169855d6a331 \
+ --hash=sha256:3126a5dbad56401989ac769aca0cb56005bfb3e2366eea0ca99d1a91c3c1ee03 \
+ --hash=sha256:31aaee5b80f672c1dc21272bcfb9cbdcfc1ea04ff50f00ed5af500b80c44fa93 \
+ --hash=sha256:3a23f0e4f957e1be65752d2dfac9a5a06b1917af8dc85deb639c3b9d02e31290 \
+ --hash=sha256:3b21b412b899fd8bd51a3046934b59a3bb068b79f70a5c6010053ac77cc53f0c \
+ --hash=sha256:3c1fd2dbe1b0af19e987f03fe66c5f5bd105a2229c1aff4ab14890b24f41d21a \
+ --hash=sha256:3f4d9e473a5ce7d27fef8b848df4dc16e283893d3f53b4a585e72c9595f3c284 \
+ --hash=sha256:3fb7bc819187b56dc48aa5c833aaf92257da8e07efdb9306156667bd2eeb491c \
+ --hash=sha256:41977aa5654023948c2dae2a81cbf9c43343954bef1cd59a154dd15a4d84c195 \
+ --hash=sha256:44d019fa8cdaf89bf29c71b39e3712143fdd0ac76725c6ef954f9957a5ea8730 \
+ --hash=sha256:46add52f4ad47a08bfb1219f3e673da972191489a33016edefdb5ea55bfa8c48 \
+ --hash=sha256:49060fd70737fad59d33ba9dcc0d83247dc9e77187de26053a19c16c9f32bd69 \
+ --hash=sha256:491e7d072a253b156fff46b78bceac4652a697aa8d7082c9c18c03d7b7917d24 \
+ --hash=sha256:4a1d68ff7ca1d3b5dee20a97a3decda7d5f15003823bf6d140c81f8561d3bc5c \
+ --hash=sha256:51d7b836acb0108d7c77df1742332cac2a1fa04a74d6dacec46e7091f0e91274 \
+ --hash=sha256:560b2cf3fb03240cd34f27409a238547488708f05b7c3924f571a60422251ec7 \
+ --hash=sha256:561926e0573ffe4a32498420a76d64b16c513e1ab413b9d28158a8764ac701e5 \
+ --hash=sha256:57b37fc887a32d44798e4d8ebfa7c9683ff3da1d5bf38f08d1bb3573ccb39106 \
+ --hash=sha256:5a71d0d2014c3275043e1170bf3d4e771493cb0dcf07be54c567155f4d8ee64b \
+ --hash=sha256:5ab4f50ff971b611d656554ea10b75f80097392c827bc32923c6eeb6386c8b00 \
+ --hash=sha256:5af7780e4a26bd7d0d989592bf9ef12ebf806b74ab709223ecca37c749872ea9 \
+ --hash=sha256:5cf109d010b4b05a105afb3d43be36a21322d345ad3111e13d15f680afef0e5b \
+ --hash=sha256:5ed466aee31294d7cdcd4d37dfe5c42c97bc29d9a5f00eacf24504358309cb9b \
+ --hash=sha256:616e89e008a93c01104161c75b4988e58716b01d62307ebfe161e52a56d2a818 \
+ --hash=sha256:62c1b7fe1f77925acf5af68b6140b8810fa87dfd4dc0a9c8568ec2fa2a10429c \
+ --hash=sha256:63efadc657488f45db1c676d81e704cac2abf3fdb892def1faea61db053127e2 \
+ --hash=sha256:63ffdbdae7d4499f4cda14eadc12ddcabef0fc0c081191bdc2247489cb698077 \
+ --hash=sha256:64c0203212098470032aabcde9356fc168f377aade3e43def61dfe17e92f2037 \
+ --hash=sha256:64d613743df53199b1aa256a7d328340da6d7078aac7705a7db9d7a791e9cfd2 \
+ --hash=sha256:659039cc50b5addcc35fcc87ae2c1833b7c0a8e5326ef631a75e4478447bcf84 \
+ --hash=sha256:6722bcef4ffc86c835574b1b2fac6b33b9fb4a889c781e67950e891591f3c55a \
+ --hash=sha256:67c3bc1760f8c99d805dcab4e644027142a53b1d5d861f18780ebdbd5d40b72a \
+ --hash=sha256:67fddeda1688f0cce2d2ae83ccf8a80f79936f2d2997d6cc2261f82fdb54a4d3 \
+ --hash=sha256:6edb63a46e65a82c26800a868e49b2cac30dd5a4218b88d74bc2c848c8ad60bb \
+ --hash=sha256:70a490b55634dc0d2606ce8a8e01b1d62459011beb368d15d76e1eaf62460e3d \
+ --hash=sha256:710cc51d4ebdcd3c1f70b232c1db1ea1344a075770422bbd4bede5708335acbe \
+ --hash=sha256:741eed508c233a76313a1c7b001f8f21b82f14327e9196ae8bd29a2cc164ae84 \
+ --hash=sha256:7b24c3492c5f4f84a37946ad9cf504910cf6a782d6a4e0689b6673c5894b4a1c \
+ --hash=sha256:7c9610fd25ebccb43fca584136f5c2fbb26802447eccd430dfdbab95a0fd5126 \
+ --hash=sha256:850ccb1d7eedb4200f4014b1c0e8a577de114fc3cd88faad646dcc9bc4bb12ad \
+ --hash=sha256:8597d23c87f59294f83bcb6229b9ed1fccee13dbba967b46930d2f1759466fee \
+ --hash=sha256:8d031aabecc4f1b6276adfb42e3aabb77c89d468bf616600e8d3a11328929053 \
+ --hash=sha256:8d30a4a1c87713060c8d1cc59a7b6c8fb6b8ef0a6900368014c76c87922a2929 \
+ --hash=sha256:8fb8de1e23a0cb2a7f53c335049c7b72b6db41aa6227cdcc0972a1de5cb39450 \
+ --hash=sha256:8fc4d94713c4697347e38faf7d6ef91547c142219bdcfc7220c4870879974244 \
+ --hash=sha256:96e38eea538c8ddf853a35727c7be0741c76c13f04148ac5c116222f50ece3b3 \
+ --hash=sha256:9acf1b2faec82d998811ecce7ae84d9005e53410773e9d37d61cdc424ba4581b \
+ --hash=sha256:9c8a956fd72c2cf1e730d01ea080341f13aa0a97a4a33b51abebe725b7ae9ca9 \
+ --hash=sha256:9ec553a99b0987efd7a3645a1a825cf29c224e494db267a83369fcc8da9aeda5 \
+ --hash=sha256:a111256a7193bea0759267b10385e5870949c239ed7b6ddbaaf57573edb38734 \
+ --hash=sha256:a32d2027a9fa67f109ff245a3252ece3ccc32cc56703e1deab6cc846a59e0585 \
+ --hash=sha256:a5c54ef4ff776d9675837ef535b3308d6e31c208d43ebc44a0f7ab8a208c68f7 \
+ --hash=sha256:adbb8edeadd431bc4477879d5d371ece7cb1334486584e0f252656dd7ffada29 \
+ --hash=sha256:adff21bc78edfe086c15eb495b900306076de378dc2337c132401fc39bd79c91 \
+ --hash=sha256:b1e7923093a376d93c6eb507c77045ae258d689ba577392846a1b3f10d0b09a9 \
+ --hash=sha256:b2c61484666ad42726029af0c00ef4541f0f3b5cdc550221f56c2343208018ee \
+ --hash=sha256:b42e9ff5376819c053da25809a8d4b6fa6e473b4856ebe42e298ac958be3d7f9 \
+ --hash=sha256:b72d0b2990ca754a9102779ac98d8597b7cb31678958562214a007f909eab78e \
+ --hash=sha256:b7dc85ea77d4abbae8bad0d3538678aedee75bceec4e2f6c8dfb1c74772e5aa5 \
+ --hash=sha256:b8a3f9a6008048fe9def7bf465180564a6e458047d2ce499149cfbe73c3ae9db \
+ --hash=sha256:baad945ed47f163ad833314f8e3288c396118934f94e7bbb9e243ce4b341a4fd \
+ --hash=sha256:bae96332410f866e5900d809298b1ed82735932986c672495f9701daacd80620 \
+ --hash=sha256:bc837c1b9631be10abfe0191537fe8009838204cec7e44827401ace390ddb567 \
+ --hash=sha256:bf3ea07d9bc8e7d03a9fbc051295462e6dbc295b894fd72457c3136e3e43d898 \
+ --hash=sha256:c22a488f7b9218e245a0025a9ba6b100e2e54700831cf4cf16833a27fba3ad01 \
+ --hash=sha256:c4b4717bdb35ae456f831a6b08d01880fff399887a6bbc526a583a406e484eea \
+ --hash=sha256:c5fc4f8def331036a7b8e981b4347ebe409981edbc8308a5ea842b8c3614fa6c \
+ --hash=sha256:c682bea068a90b764577bdb78a60a4c1d1606daf9cd4c893832a37c7cc9d9026 \
+ --hash=sha256:c90c0f63df322be920eda6ce622e3083d8906ba267f8220fe7873213b8b4430e \
+ --hash=sha256:c9c53be232c2e206ef9cdbad81a48bfa74c3d3f08bcf8124630a8a748aad993e \
+ --hash=sha256:cbd18dd5e2df96b580487b5745adf57ef64ad89ba2d9662fc3c19386acce7db8 \
+ --hash=sha256:cf0d73f50e7b6935677854f6e8e31d499ca7064dd24734f703e060f5b237d883 \
+ --hash=sha256:d284fb8d94d5855d60c44fefcab4bf966f1da6fada73992b01f6f0c9bc0c6702 \
+ --hash=sha256:d28bb3c26762358dadf3e5bf0bccd29ae987d65e6988d2e6f49829c76b003c09 \
+ --hash=sha256:d5bf78d0e05e45cfdd66558893938d59afe3d1b1a824a202039b20e607d25a72 \
+ --hash=sha256:d5f91b1c27fc22a57993d5a5cb8a627cb8ed4b10502716fac1ffbfe1d19d84e8 \
+ --hash=sha256:d8f80521644426d451e70f00c7974240cab8f6ee088aedaa9af2697153ab7805 \
+ --hash=sha256:da3d7ec75dc83bb18bca888b5edfae0656a26849056c59e05a7728badd17e7af \
+ --hash=sha256:dab907db06fc593645e73109acf4581ba5b548897d28b9348dc41ddc8343b2d3 \
+ --hash=sha256:de5ba8763e56b793561f43bed197c9ea55776daa5e9a6b91eed68a909bc9cdbf \
+ --hash=sha256:e03a5f21a5ce96a9441b8cb32719a8b88ed5388f53e0f339c5bcf54f1317f9d0 \
+ --hash=sha256:e34e97bda77eb63242a410243c071e28ac7e0d8c0948c5ee658498690a4b2f2f \
+ --hash=sha256:e431cfc9caf44c1d5459ff77d4e64cbf85fddb6a35dad836a15c6a9ec23087c1 \
+ --hash=sha256:eab2cd170150e70153de16896a1774e3a1dca80154c56b54d7a812c479a7165e \
+ --hash=sha256:ee6162b77d49a9939229df666dfa8af3e656b6701b54c4c84966d740e189264e \
+ --hash=sha256:f17d61a28b4b3e0e3e2ba98490c70501403b4d196f78732439160e7fd3678127 \
+ --hash=sha256:f3bd327cdfa118bc1ce69c214c2678571d5bd39b8ccd0ebf43a54db00541ba9a \
+ --hash=sha256:f4444a83f946605990c98f625cdd3d2725bfb818158760c5748c653170a20e0e \
+ --hash=sha256:fb08c276dd02dac3a284acdd02cacc630d2e3cd6572a4b85519f35cbd133c3de
+ # via anthropic
+packaging==26.2 \
+ --hash=sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e \
+ --hash=sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661
+ # via pytest
+pluggy==1.6.0 \
+ --hash=sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3 \
+ --hash=sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746
+ # via pytest
+pycparser==3.0 \
+ --hash=sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29 \
+ --hash=sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992
+ # via cffi
+pydantic==2.13.4 \
+ --hash=sha256:45a282cde31d808236fd7ea9d919b128653c8b38b393d1c4ab335c62924d9aba \
+ --hash=sha256:c40756b57adaa8b1efeeced5c196f3f3b7c435f90e84ea7f443901bec8099ef6
+ # via
+ # -r backend/requirements.txt
+ # anthropic
+ # fastapi
+pydantic-core==2.46.4 \
+ --hash=sha256:00c603d540afdd6b80eb39f078f33ebd46211f02f33e34a32d9f053bba711de0 \
+ --hash=sha256:0186750b482eefa11d7f435892b09c5c606193ef3375bcf94aa00ae6bfb66262 \
+ --hash=sha256:041bde0a48fd37cf71cab1c9d56d3e8625a3793fef1f7dd232b3ff37e978ecda \
+ --hash=sha256:0c563b08bca408dc7f65f700633d8442fffb2421fc47b8101377e9fd65051ff0 \
+ --hash=sha256:0cbe8b01f948de4286c74cdd6c667aceb38f5c1e26f0693b3983d9d74887c65e \
+ --hash=sha256:0ce40cd7b21210e99342afafbd4d0f76d784eb5b1d60f3bdc566be4983c6c73b \
+ --hash=sha256:0e96592440881c74a213e5ad528e2b24d3d4f940de2766bed9010ab1d9e51594 \
+ --hash=sha256:10e17cbb10a330363733efc4d7c4d0dd827ac0909b8f6a6542298fed1ea62f29 \
+ --hash=sha256:133878133d271ade3d41d1bfb2a45ec38dbdbda40bc065921c6b04e4630127e2 \
+ --hash=sha256:14d4edf427bdcf950a8a02d7cb44a08614388dd6e1bdcbf4f67504fa7887da9c \
+ --hash=sha256:14f4c5d6db102bd796a627bbb3a17b4cf4574b9ae861d8b7c9a9661c6dd3362d \
+ --hash=sha256:17299feefe090f2caa5b8e37222bb5f663e4935a8bfa6931d4102e5df1a9f398 \
+ --hash=sha256:184c081504d17f1c1066e430e117142b2c77d9448a97f7b65c6ac9fd9aee238d \
+ --hash=sha256:18e5ceec2ab67e6d5f1a9085e5a24c9c4e2ac4545730bfe668680bca05e555f3 \
+ --hash=sha256:19e51f073cd3df251856a8a4189fbdf1de4012c3ebacfb1884f94f1eb406079f \
+ --hash=sha256:1a7dd0b3ee80d90150e3495a3a13ac34dbcbfd4f012996a6a1d8900e91b5c0fb \
+ --hash=sha256:1d8ba486450b14f3b1d63bc521d410ec7565e52f887b9fb671791886436a42f7 \
+ --hash=sha256:2108ba5c1c1eca18030634489dc544844144ee36357f2f9f780b93e7ddbb44b5 \
+ --hash=sha256:228ee9bae8bef5b1e97ec58302f80357c37199e0d0a99174e138d28e6957b9d9 \
+ --hash=sha256:23ace664830ee0bfe014a0c7bc248b1f7f25ed7ad103852c317624a1083af462 \
+ --hash=sha256:2412e734dcb48da14d4e4006b82b46b74f2518b8a26ee7e58c6844a6cd6d03c4 \
+ --hash=sha256:29c61fc04a3d840155ff08e475a04809278972fe6aef51e2720554e96367e34b \
+ --hash=sha256:2f84c03c8607173d16b5a854ec68a2f9079ae03237a54fb506d13af47e1d018d \
+ --hash=sha256:3009f12e4e90b7f88b4f9adb1b0c4a3d58fe7820f3238c190047209d148026df \
+ --hash=sha256:3245406455a5d98187ec35530fd772b1d799b26667980872c8d4614991e2c4a2 \
+ --hash=sha256:3447661d99f75a3683a4cf5c87da72f2161964611864dbbeac7fbb118bb4bfc0 \
+ --hash=sha256:372429a130e469c9cd698925ce5fc50940b7a1336b0d82038e63d5bbc4edc519 \
+ --hash=sha256:395aebd9183f9d112f569aeb5b2214d1a10a33bec8456447f7fbdfa51d38d4cd \
+ --hash=sha256:3a233125ac121aa3ffba9a2b59edfc4a985a76092dc8279586ab4b71390875e7 \
+ --hash=sha256:3be77f45df024d789a672ae34f8b06fb346c4f9f46ea714956660ea4862e89ac \
+ --hash=sha256:3bf92c5d0e00fefaab325a4d27828fe6b6e2a21848686b5b60d2d9eeb09d76c6 \
+ --hash=sha256:3ecbc122d18468d06ca279dc26a8c2e2d5acb10943bb35e36ae92096dc3b5565 \
+ --hash=sha256:3fb702cd90b0446a3a1c5e470bfa0dd23c0233b676a9099ddcc964fa6ca13898 \
+ --hash=sha256:428e04521a40150c85216fc8b85e8d39fece235a9cf5e383761238c7fa9b96fb \
+ --hash=sha256:432c179df7874eeb73307aad2df0755e1ae0efa61ff0ea89b93e194411ae3928 \
+ --hash=sha256:4a05d69cba51d852c5c3e92758653245a50c0b646ced0cf05bd793ed592839d6 \
+ --hash=sha256:4c63ebc82684aa89d9a3bcbd13d515b3be44250dc68dd3bd81526c1cb31286c3 \
+ --hash=sha256:4fc73cb559bdb54b1134a706a2802a4cddd27a0633f5abb7e53056268751ac6a \
+ --hash=sha256:4fcbe087dbc2068af7eda3aa87634eba216dbda64d1ae73c8684b621d33f6596 \
+ --hash=sha256:56cb4851bcaf3d117eddcef4fe66afd750a50274b0da8e22be256d10e5611987 \
+ --hash=sha256:5855698a4856556d86e8e6cd8434bc3ac0314ee8e12089ae0e143f64c6256e4e \
+ --hash=sha256:5a4330cdbc57162e4b3aa303f588ba752257694c9c9be3e7ebb11b4aca659b5d \
+ --hash=sha256:5b712b53160b79a5850310b912a5ef8e57e56947c8ad690c227f5c9d7e561712 \
+ --hash=sha256:5d5902252db0d3cedf8d4a1bc68f70eeb430f7e4c7104c8c476753519b423008 \
+ --hash=sha256:617d7e2ca7dcb8c5cf6bcb8c59b8832c94b36196bbf1cbd1bfb56ed341905edd \
+ --hash=sha256:62f875393d7f270851f20523dd2e29f082bcc82292d66db2b64ea71f64b6e1c1 \
+ --hash=sha256:633147d34cf4550417f12e2b1a0383973bdf5cdfde212cb09e9a581cf10820be \
+ --hash=sha256:66ce7632c22d837c95301830e111ad0128a32b8207533b60896a96c4915192ea \
+ --hash=sha256:6b3ace8194b0e5204818c92802dcdca7fc6d88aabbb799d7c795540d9cd6d292 \
+ --hash=sha256:6f2eeda33a839975441c86a4119e1383c50b47faf0cbb5176985565c6bb02c33 \
+ --hash=sha256:7027560ee92211647d0d34e3f7cd6f50da56399d26a9c8ad0da286d3869a53f3 \
+ --hash=sha256:7283d57845ecf5a163403eb0702dfc220cc4fbdd18919cb5ccea4f95ee1cdab4 \
+ --hash=sha256:7a5f930472650a82629163023e630d160863fce524c616f4e5186e5de9d9a49b \
+ --hash=sha256:7bfb192b3f4b9e8a89b6277b6ce787564f62cfd272055f6e685726b111dc7826 \
+ --hash=sha256:811ff8e9c313ab425368bcbb36e5c4ebd7108c2bbf4e4089cfbb0b01eff63fac \
+ --hash=sha256:8233f2947cf85404441fd7e0085f53b10c93e0ee78611099b5c7237e36aacbf7 \
+ --hash=sha256:82cf5301172168103724d49a1444d3378cb20cdee30b116a1bd6031236298a5d \
+ --hash=sha256:8358a950c8909158e3df31538a7e4edc2d7265a7c54b47f0864d9e5bae9dcebf \
+ --hash=sha256:85bb3611ff1802f3ee7fdd7dbff26b56f343fb432d57a4728fdd49b6ef35e2f4 \
+ --hash=sha256:86e1a4418c6cd97d60c95c71164158eaf7324fae7b0923264016baa993eba6fc \
+ --hash=sha256:8b9bab013d1c7a79d3501ff86d0bc9c31bf587db4551677b96bec07df78c6b15 \
+ --hash=sha256:8c5dac79fa1614d1e06ca695109c6105923bd9c7d1d6c918d4e637b7e6b32fd3 \
+ --hash=sha256:8d0820e8192167f80d88d64038e609c31452eeca865b4e1d9950a27a4609b00b \
+ --hash=sha256:8daafc69c93ee8a0204506a3b6b30f586ef54028f52aeeeb5c4cfc5184fd5914 \
+ --hash=sha256:9037063db01f09b09e237c282b6792bd4da634b5402c4e7f0c61effed7701a04 \
+ --hash=sha256:905a0ed8ea6f2d61c1738835f99b699348d7857379083e5fc497fa0c967a407c \
+ --hash=sha256:90884113d8b48f760e9587002789ddd741e76ab9f89518cd1e43b1f1a52ec44b \
+ --hash=sha256:91a06d2e259ecfbd8c901d70c3c507900458498142b3026a296b7de4d1322cc9 \
+ --hash=sha256:926c9541b14b12b1681dca8a0b75feb510b06c6341b70a8e500c2fdcff837cce \
+ --hash=sha256:9401557acd873c3a7f3eb9383edef8ac4968f9510e340f4808d427e75667e7b4 \
+ --hash=sha256:9551187363ffc0de2a00b2e47c25aeaeb1020b69b668762966df15fc5659dd5a \
+ --hash=sha256:962ccbab7b642487b1d8b7df90ef677e03134cf1fd8880bf698649b22a69371f \
+ --hash=sha256:97e7cf2be5c77b7d1a9713a05605d49460d02c6078d38d8bef3cbe323c548424 \
+ --hash=sha256:9aa768456404a8bf48a4406685ac2bec8e72b62c69313734fa3b73cf33b3a894 \
+ --hash=sha256:9bc519fbf2b7578398853d815009ae5e4d4603d12f4e3f91da8c06852d3da3e9 \
+ --hash=sha256:9d56801be94b86a9da183e5f3766e6310752b99ff647e38b09a9500d88e46e76 \
+ --hash=sha256:9f444c499b3eefd3a92e348059471ea0c3a6e303d9c1cec09fa748fd9f895201 \
+ --hash=sha256:9fa8ae11da9e2b3126c6426f147e0fba88d96d65921799bb30c6abd1cb2c97fb \
+ --hash=sha256:a0f62d0a58f4e7da165457e995725421e0064f2255d8eccebc49f41bbc23b109 \
+ --hash=sha256:a396dcc17e5a0b164dbe026896245a4fa9ff402edca1dff0be3d53a517f74de4 \
+ --hash=sha256:aaa2a54443eff1950ba5ddc6b6ccda0d9c84a364276a62f969bdf2a390650848 \
+ --hash=sha256:ad785e92e6dc634c21555edc8bd6b64957ab844541bcb96a1366c202951ae526 \
+ --hash=sha256:af8244b2bef6aaad6d92cda81372de7f8c8d36c9f0c3ea36e827c60e7d9467a0 \
+ --hash=sha256:b078afbc25f3a1436c7a1d2cd3e322497ee99615ba97c563566fdf46aff1ee01 \
+ --hash=sha256:b2f69dec1725e79a012d920df1707de5caf7ed5e08f3be4435e25803efc47458 \
+ --hash=sha256:b8458003118a712e66286df6a707db01c52c0f52f7db8e4a38f0da1d3b94fc4e \
+ --hash=sha256:bb63e0198ca18aad131c089b9204c23079c3afa95487e561f4c522d519e55aba \
+ --hash=sha256:bfec22eab3c8cc2ceec0248aec886624116dc079afa027ecc8ad4a7e62010f8a \
+ --hash=sha256:c1747f85cee84c26985853c6f3d9bd3e75da5212912443fa111c113b9c246f39 \
+ --hash=sha256:c1b3f518abeca3aa13c712fd202306e145abf59a18b094a6bafb2d2bbf59192c \
+ --hash=sha256:c50f2528cf200c5eed56faf3f4e22fcd5f38c157a8b78576e6ba3168ec35f000 \
+ --hash=sha256:c68fcd102d71ea85c5b2dfac3f4f8476eff42a9e078fd5faefff6d145063536b \
+ --hash=sha256:c7a7bd4e39e8e4c12c39cd480356842b6a8a06e41b23a55a5e3e191718838ddf \
+ --hash=sha256:c94f0688e7b8d0a67abf40e57a7eaaecd17cc9586706a31b76c031f63df052b4 \
+ --hash=sha256:cbaf13819775b7f769bf4a1f066cb6df7a28d4480081a589828ef190226881cd \
+ --hash=sha256:cd2213145bcc2ba85884d0ac63d222fece9209678f77b9b4d76f054c561adb28 \
+ --hash=sha256:ce5c1d2a8b27468f433ca974829c44060b8097eedc39933e3c206a90ee49c4a9 \
+ --hash=sha256:d396ec2b979760aaf3218e76c24e65bd0aca24983298653b3a9d7a45f9e47b30 \
+ --hash=sha256:d51026d73fcfd93610abc7b27789c26b313920fcfb20e27462d74a7f8b06e983 \
+ --hash=sha256:d80ee3d731373b24cebbc10d689ca4ee1875caf0d5703a245db18efd4dd37fc1 \
+ --hash=sha256:d995260fdf4e1db774581b4900e0f832abe3c7c84996726bbc161b19c8f29e76 \
+ --hash=sha256:da4b951fe36dc7c3a1ccb4e3cd1747c3542b8c9ceede8fc86cae054e764485f5 \
+ --hash=sha256:daa27d92c36f24388fe3ad306b174781c747627f134452e4f128ea00ce1fe8c4 \
+ --hash=sha256:db06ffe51636ffe9ca531fe9023dd64bdd794be8754cb5df57c5498ae5b518a7 \
+ --hash=sha256:e0d65b8c354be7fb5f720c3caa8bc940bc2d20ce749c8e06135f07f8ed95dd7c \
+ --hash=sha256:e68b7a074f65a2fd746c52a7ce6142ab7006074ac269ace0c25cd8ba171f8066 \
+ --hash=sha256:e739fee756ba1010f8bcccb534252e85a35fe45ae92c295a06059ce58b74ccd3 \
+ --hash=sha256:e846ae7835bf0703ae43f534ab79a867146dadd59dc9ca5c8b53d5c8f7c9ef02 \
+ --hash=sha256:e9c26f834c65f5752f3f06cb08cb86a913ceb7274d0db6e267808a708b46bc89 \
+ --hash=sha256:ea793e075b70290d89d8142074262885d3f7da19634845135751bd6344f73b50 \
+ --hash=sha256:f027324c56cd5406ca49c124b0db10e56c69064fec039acc571c29020cc87c76 \
+ --hash=sha256:f13a646d65d09fbf1bc6b3a9635d30095c8e7e5cc419ff35ecc563c5fd04cd49 \
+ --hash=sha256:f47286a97f0bc9b8859519809077b91b2cefe4ae47fcbf5e466a009c1c5d742b \
+ --hash=sha256:f747929cf940cddb5b3668a390056ddd5ba2e5010615ea2dcf4f9c4f3ab8791d \
+ --hash=sha256:f99626688942fb746e545232e7726926f3be91b5975f8b55327665fafda991c7 \
+ --hash=sha256:f9fa868638bf362d3d138ea55829cefb3d5f4b0d7f142234382a15e2485dbec4 \
+ --hash=sha256:fbdb89b3e1c94a30cc5edfce477c6e6a5dc4d8f84665b455c27582f211a1c72c \
+ --hash=sha256:fc010ab034c8c7452522748bf937df58020d256ccae0874463d1f4d01758af8e \
+ --hash=sha256:fc3e9034a63de20e15e8ade85358bc6efc614008cab72898b4b4952bea0509ff \
+ --hash=sha256:fd8b3d9fd264be37976686c7f65cd52a83f5e84f4bfd2adf9c1d469676bbb6ae
+ # via pydantic
+pygments==2.20.0 \
+ --hash=sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f \
+ --hash=sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176
+ # via pytest
+pytest==9.1.1 \
+ --hash=sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313 \
+ --hash=sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c
+ # via -r requirements-dev.in
+python-dotenv==1.2.2 \
+ --hash=sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a \
+ --hash=sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3
+ # via -r backend/requirements.txt
+sniffio==1.3.1 \
+ --hash=sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2 \
+ --hash=sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc
+ # via anthropic
+sqlite-vec==0.1.9 \
+ --hash=sha256:1515727990b49e79bcaf75fdee2ffc7d461f8b66905013231251f1c8938e7786 \
+ --hash=sha256:1b62a7f0a060d9475575d4e599bbf94a13d85af896bc1ce86ee80d1b5b48e5fb \
+ --hash=sha256:1d52e30513bae4cc9778ddbf6145610434081be4c3afe57cd877893bad9f6b6c \
+ --hash=sha256:4a28dc12fa4b53d7b1dced22da2488fade444e96b5d16fd2d698cd670675cf32 \
+ --hash=sha256:4e921e592f24a5f9a18f590b6ddd530eb637e2d474e3b1972f9bbeb773aa3cb9
+ # via -r backend/requirements.txt
+starlette==1.3.1 \
+ --hash=sha256:05d0213193f2fbaae60e2ecb593b4add4262ad4e46536b54abe36f11a71724e0 \
+ --hash=sha256:c7372aae11c3c3f26a42df7bd626cec2f47d03483d261d369516a615a53714c6
+ # via fastapi
+typing-extensions==4.16.0 \
+ --hash=sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8 \
+ --hash=sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5
+ # via
+ # anthropic
+ # anyio
+ # fastapi
+ # pydantic
+ # pydantic-core
+ # starlette
+ # typing-inspection
+typing-inspection==0.4.2 \
+ --hash=sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7 \
+ --hash=sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464
+ # via
+ # fastapi
+ # pydantic
+uvicorn==0.52.0 \
+ --hash=sha256:3d887809810b89ed33501bcf0a9aba469b06ecd608158efce04bd6b48d8c9b08 \
+ --hash=sha256:ca8876ad6c1983f394157c168b39d52f6dd56dabf5602fa0982751cffc2293ae
+ # via -r backend/requirements.txt
diff --git a/scripts/bootstrap_dev.sh b/scripts/bootstrap_dev.sh
new file mode 100755
index 00000000..01d3f41a
--- /dev/null
+++ b/scripts/bootstrap_dev.sh
@@ -0,0 +1,65 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+ROOT="$(cd "$(dirname "$0")/.." && pwd)"
+PYTHON_BIN="${PYTHON_BIN:-python3.12}"
+VENV_PATH="${CORTEX_VENV:-$ROOT/.venv}"
+CHECK_ONLY=0
+
+if [[ "${1:-}" == "--check" ]]; then
+ CHECK_ONLY=1
+elif [[ $# -gt 0 ]]; then
+ echo "Usage: scripts/bootstrap_dev.sh [--check]" >&2
+ exit 2
+fi
+
+if ! command -v "$PYTHON_BIN" >/dev/null 2>&1; then
+ cat >&2 <&2 < int:
+ with socket.socket() as sock:
+ sock.bind(("127.0.0.1", 0))
+ return int(sock.getsockname()[1])
+
+
+def _json_request(
+ base_url: str,
+ path: str,
+ *,
+ token: str,
+ method: str = "GET",
+ body: dict | None = None,
+ query: dict[str, str] | None = None,
+ timeout_seconds: float = 10,
+) -> dict:
+ url = f"{base_url}{path}"
+ if query:
+ url = f"{url}?{parse.urlencode(query)}"
+ payload = json.dumps(body).encode("utf-8") if body is not None else None
+ headers = {"Authorization": f"Bearer {token}"}
+ if payload is not None:
+ headers["Content-Type"] = "application/json"
+ with request.urlopen(
+ request.Request(url, data=payload, headers=headers, method=method),
+ timeout=timeout_seconds,
+ ) as response:
+ return json.loads(response.read().decode("utf-8"))
+
+
+def _server_log_tail(path: Path, *, limit: int = 4000) -> str:
+ try:
+ output = path.read_text(encoding="utf-8", errors="replace")
+ except OSError as exc:
+ return f""
+ return output[-limit:] or ""
+
+
+def _wait_for_json_endpoint(
+ process: subprocess.Popen,
+ base_url: str,
+ path: str,
+ *,
+ token: str,
+ deadline: float,
+ server_log_path: Path,
+) -> dict:
+ while True:
+ if process.poll() is not None:
+ raise RuntimeError(
+ f"bundled runtime exited with {process.returncode}; server output:\n"
+ f"{_server_log_tail(server_log_path)}"
+ )
+ remaining = deadline - time.monotonic()
+ if remaining <= 0:
+ raise RuntimeError(
+ f"bundled runtime endpoint {path} was not ready before the deadline; "
+ f"server output:\n{_server_log_tail(server_log_path)}"
+ )
+ try:
+ return _json_request(
+ base_url,
+ path,
+ token=token,
+ timeout_seconds=min(2, max(0.1, remaining)),
+ )
+ except (error.URLError, TimeoutError, json.JSONDecodeError):
+ time.sleep(0.2)
+
+
+def main() -> None:
+ parser = argparse.ArgumentParser()
+ parser.add_argument("--app", type=Path, default=Path("macos/build/Cortex.app"))
+ parser.add_argument(
+ "--startup-timeout-seconds",
+ type=float,
+ default=90,
+ help="Maximum time to wait for a cold bundled runtime to become healthy.",
+ )
+ args = parser.parse_args()
+ if args.startup_timeout_seconds <= 0:
+ parser.error("--startup-timeout-seconds must be greater than zero")
+
+ app = args.app.expanduser().resolve()
+ resources = app / "Contents" / "Resources"
+ python = (
+ app
+ / "Contents"
+ / "Frameworks"
+ / "Python.framework"
+ / "Versions"
+ / "3.12"
+ / "bin"
+ / "python3"
+ )
+ required = [
+ python,
+ resources / "backend" / "app" / "standalone_server.pyc",
+ resources / "python",
+ resources / "model2vec",
+ ]
+ missing = [str(path) for path in required if not path.exists()]
+ if missing:
+ raise SystemExit(f"bundled runtime is incomplete: {missing}")
+
+ token = "cortex-bundled-runtime-smoke-token"
+ port = _free_port()
+ base_url = f"http://127.0.0.1:{port}"
+ with tempfile.TemporaryDirectory(prefix="cortex-bundled-runtime-") as tmp:
+ root = Path(tmp)
+ server_log_path = root / "server.log"
+ env = os.environ.copy()
+ env.update(
+ {
+ "CORTEX_API_KEY": token,
+ "CORTEX_AUTO_APPROVE_CAPTURES": "1",
+ "CORTEX_DB_PATH": str(root / "Cortex.vault" / "index.sqlite"),
+ "CORTEX_VAULT_PATH": str(root / "Cortex.vault"),
+ "CORTEX_EMBEDDING_PROVIDER": "model2vec",
+ "CORTEX_EMBEDDING_STRICT": "1",
+ "CORTEX_MODEL2VEC_PATH": str(resources / "model2vec"),
+ "CORTEX_PUBLIC_BASE_URL": base_url,
+ "HF_HUB_OFFLINE": "1",
+ "PYTHONNOUSERSITE": "1",
+ "PYTHONDONTWRITEBYTECODE": "1",
+ "PYTHONPATH": os.pathsep.join(
+ [str(resources / "backend"), str(resources / "python")]
+ ),
+ }
+ )
+ with server_log_path.open("w", encoding="utf-8") as server_log:
+ process = subprocess.Popen(
+ [
+ str(python),
+ "-S",
+ "-m",
+ "app.standalone_server",
+ "--host",
+ "127.0.0.1",
+ "--port",
+ str(port),
+ ],
+ cwd=resources,
+ env=env,
+ stdout=server_log,
+ stderr=subprocess.STDOUT,
+ text=True,
+ )
+ try:
+ deadline = time.monotonic() + args.startup_timeout_seconds
+ health = _wait_for_json_endpoint(
+ process,
+ base_url,
+ "/health",
+ token=token,
+ deadline=deadline,
+ server_log_path=server_log_path,
+ )
+
+ encoded_health = json.dumps(health)
+ assert health["status"] == "ok", health
+ assert "db_path" not in encoded_health and "vault_path" not in encoded_health
+ ready = _wait_for_json_endpoint(
+ process,
+ base_url,
+ "/ready",
+ token=token,
+ deadline=deadline,
+ server_log_path=server_log_path,
+ )
+ assert ready["status"] == "ok", ready
+
+ _json_request(
+ base_url,
+ "/v1/captures",
+ token=token,
+ method="POST",
+ body={
+ "content": "Bundled runtime smoke launches Friday after verification.",
+ "source": "bundled-runtime-smoke",
+ "source_url": "cortex-smoke://bundled-runtime",
+ },
+ )
+ answer = _json_request(
+ base_url,
+ "/v1/ask",
+ token=token,
+ query={"query": "When does the bundled runtime smoke launch?"},
+ )
+ assert answer.get("status") == "cited", answer
+ assert answer.get("citations"), answer
+ except Exception as exc:
+ if "server output:" in str(exc):
+ raise
+ raise RuntimeError(
+ f"bundled runtime smoke failed: {exc}; server output:\n"
+ f"{_server_log_tail(server_log_path)}"
+ ) from exc
+ finally:
+ process.terminate()
+ try:
+ process.wait(timeout=5)
+ except subprocess.TimeoutExpired:
+ process.kill()
+ process.wait(timeout=5)
+
+ print("bundled runtime smoke: health, readiness, capture, and cited Ask passed")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/scripts/check_distribution_site.py b/scripts/check_distribution_site.py
index 2ed2c572..16ce77e6 100644
--- a/scripts/check_distribution_site.py
+++ b/scripts/check_distribution_site.py
@@ -150,6 +150,69 @@ def validate_manifest(site_dir: Path) -> list[str]:
return errors
+def validate_release_surfaces(site_dir: Path) -> list[str]:
+ """Keep static fallbacks and legacy distribution metadata on the canonical feed."""
+ errors: list[str] = []
+ manifest_path = site_dir / "downloads" / "latest.json"
+ app_js_path = site_dir / "app.js"
+ distribution_path = site_dir / "downloads" / "distribution.json"
+ if not manifest_path.exists():
+ return errors
+ try:
+ manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
+ except json.JSONDecodeError:
+ return errors # validate_manifest reports the parse error.
+
+ canonical = {
+ artifact.get("kind"): artifact
+ for artifact in manifest.get("artifacts", [])
+ if artifact.get("kind") in {"dmg", "zip"}
+ }
+ if app_js_path.exists():
+ app_js = app_js_path.read_text(encoding="utf-8")
+ required_js_tokens = [
+ f'version: "{manifest.get("version")}"',
+ f'build: "{manifest.get("build")}"',
+ f'channel: "{manifest.get("channel")}"',
+ *[
+ str(artifact.get(field) or "")
+ for artifact in canonical.values()
+ for field in ("filename", "url")
+ ],
+ ]
+ for token in required_js_tokens:
+ if token and token not in app_js:
+ errors.append(f"app.js: release fallback is not aligned with latest.json ({token!r})")
+
+ if distribution_path.exists():
+ try:
+ distribution = json.loads(distribution_path.read_text(encoding="utf-8"))
+ except json.JSONDecodeError as exc:
+ errors.append(f"downloads/distribution.json is invalid JSON: {exc}")
+ else:
+ for field in ("version", "build", "channel"):
+ if distribution.get(field) != manifest.get(field):
+ errors.append(
+ f"downloads/distribution.json: {field} does not match latest.json"
+ )
+ distribution_artifacts = {
+ artifact.get("kind"): artifact
+ for artifact in distribution.get("artifacts", [])
+ if artifact.get("kind") in {"dmg", "zip"}
+ }
+ for kind, artifact in canonical.items():
+ candidate = distribution_artifacts.get(kind)
+ if candidate is None:
+ errors.append(f"downloads/distribution.json: missing {kind} artifact")
+ continue
+ for field in ("filename", "url", "sha256", "size_bytes"):
+ if candidate.get(field) != artifact.get(field):
+ errors.append(
+ f"downloads/distribution.json: {kind} {field} does not match latest.json"
+ )
+ return errors
+
+
def self_test() -> list[str]:
"""Exercise both manifest branches in a throwaway site dir.
@@ -245,11 +308,12 @@ def main() -> None:
else:
errors.extend(validate_html(site_dir))
errors.extend(validate_manifest(site_dir))
+ errors.extend(validate_release_surfaces(site_dir))
if errors:
print(json.dumps({"status": "error", "site_dir": str(site_dir), "errors": errors}, indent=2))
raise SystemExit(1)
- print(json.dumps({"status": "ok", "site_dir": str(site_dir), "checks": ["html-links", "release-manifest", "artifact-hashes"]}, indent=2))
+ print(json.dumps({"status": "ok", "site_dir": str(site_dir), "checks": ["html-links", "release-manifest", "release-surface-parity", "artifact-hashes"]}, indent=2))
if __name__ == "__main__":
diff --git a/scripts/check_docs_current.py b/scripts/check_docs_current.py
index bec4b866..e31c7083 100644
--- a/scripts/check_docs_current.py
+++ b/scripts/check_docs_current.py
@@ -27,6 +27,9 @@
"README.md": (
"Five-tab product flow",
"Model, Sources, Review, Ask, Trust",
+ "pip install -r backend/runtime-requirements.txt",
+ "new releases arrive automatically",
+ "checks the signed release feed",
),
"docs/CAPTURE_SURFACES.md": (
"Save tab",
@@ -53,6 +56,9 @@
"Local-first Cortex beta with installer, update manifest, capture, MCP, and trust controls.",
"Connect MCP or an Obsidian/local notes vault",
"Those are appropriate for the public-beta release track, not the local-first MVP package.",
+ "The app and DMG are not notarized or stapled.",
+ "first-100 release track is explicitly the unnotarized",
+ "binaries referenced by the signed update feed",
),
"docs/OPERATIONAL_READINESS.md": (
"context pack contains stale content",
@@ -61,6 +67,12 @@
"docs/DISTRIBUTION.md": (
"Cortex gives ChatGPT, Claude, Cursor, and MCP agents your memory",
"personal operating model for agents",
+ "current path is an unnotarized direct",
+ "first notarized Release has not been published yet",
+ "checks the signed update feed",
+ ),
+ "docs/PRODUCTION_READINESS.md": (
+ "Add app notarization and signed installer",
),
"docs/APPLE_RELEASE.md": (
"local-first MCP/vault beta",
@@ -74,6 +86,10 @@
"Complete first-run setup with a local vault, MCP or Obsidian connection",
"Local-first Cortex beta with bundled backend, capture, MCP, and trust controls.",
),
+ "site/index.html": (
+ "ad-hoc signed for local beta testing",
+ "not yet notarized for broad public distribution",
+ ),
"macos/Sources/CortexApp.swift": (
"Review Source Import",
"Import to Model",
@@ -227,6 +243,30 @@
"Capture, Review, Reuse, Return",
)
+REQUIRED_SNIPPETS: dict[str, tuple[str, ...]] = {
+ "README.md": (
+ "docs/README.md",
+ "make setup",
+ "examples/README.md",
+ "feat/pairwise-twin-eval/docs/PAIRWISE_TWIN_EVALUATION.md",
+ ),
+ "backend/README.md": (
+ "bundled on-device Model2Vec",
+ "cortex-hash-v1",
+ ),
+ "docs/ARCHITECTURE.md": (
+ "bundled Model2Vec embeddings when available",
+ ),
+ "docs/README.md": (
+ "trace-cortex/cortex-app",
+ "site/downloads/latest.json",
+ "feat/pairwise-twin-eval",
+ ),
+ "site/index.html": (
+ "Developer ID signed, notarized by Apple",
+ ),
+}
+
def run_command(command: list[str]) -> dict[str, object]:
completed = subprocess.run(command, cwd=ROOT, text=True, capture_output=True)
@@ -252,6 +292,21 @@ def phrase_errors() -> list[str]:
errors.append(f"{relative_path}: stale phrase still present: {phrase!r}")
errors.extend(primary_ui_errors())
errors.extend(scoped_stale_copy_errors())
+ errors.extend(required_snippet_errors())
+ return errors
+
+
+def required_snippet_errors() -> list[str]:
+ errors: list[str] = []
+ for relative_path, snippets in REQUIRED_SNIPPETS.items():
+ path = ROOT / relative_path
+ if not path.exists():
+ errors.append(f"{relative_path}: missing")
+ continue
+ text = path.read_text(encoding="utf-8")
+ for snippet in snippets:
+ if snippet not in text:
+ errors.append(f"{relative_path}: required current-doc reference missing: {snippet!r}")
return errors
@@ -440,6 +495,7 @@ def main() -> None:
"site-visible-trust-step",
"generated-beta-metadata-language",
"user-operator-loop-language",
+ "required-doc-index",
"direct-release-manifest",
"mcp-config",
],
diff --git a/scripts/check_markdown_links.py b/scripts/check_markdown_links.py
new file mode 100644
index 00000000..a2374223
--- /dev/null
+++ b/scripts/check_markdown_links.py
@@ -0,0 +1,75 @@
+#!/usr/bin/env python3
+"""Fail when a repository Markdown file points at a missing local path."""
+
+from __future__ import annotations
+
+import re
+from pathlib import Path
+from urllib.parse import unquote, urlsplit
+
+
+ROOT = Path(__file__).resolve().parents[1]
+EXCLUDED_PARTS = {
+ ".git",
+ ".venv",
+ "build",
+ "dist",
+ "dist-test",
+ "node_modules",
+ "release-artifacts",
+}
+INLINE_LINK = re.compile(
+ r"!?\[[^\]]*]\(\s*(?P<[^>]+>|[^)\s]+)(?:\s+['\"][^)]*)?\)"
+)
+REFERENCE_LINK = re.compile(r"^\s*\[[^\]]+]:\s*(?P<[^>]+>|\S+)", re.MULTILINE)
+
+
+def _markdown_files() -> list[Path]:
+ return sorted(
+ path
+ for path in ROOT.rglob("*.md")
+ if not any(part in EXCLUDED_PARTS for part in path.relative_to(ROOT).parts)
+ )
+
+
+def _local_target(raw: str) -> str | None:
+ target = raw.strip().strip("<>")
+ if not target or target.startswith("#"):
+ return None
+ parsed = urlsplit(target)
+ if parsed.scheme or parsed.netloc:
+ return None
+ return unquote(parsed.path)
+
+
+def main() -> int:
+ errors: list[str] = []
+ checked = 0
+ for document in _markdown_files():
+ text = document.read_text(encoding="utf-8")
+ matches = [*INLINE_LINK.finditer(text), *REFERENCE_LINK.finditer(text)]
+ for match in matches:
+ raw = match.group("target")
+ target = _local_target(raw)
+ if target is None:
+ continue
+ checked += 1
+ destination = (
+ ROOT / target.lstrip("/")
+ if target.startswith("/")
+ else document.parent / target
+ )
+ if not destination.exists():
+ line = text.count("\n", 0, match.start()) + 1
+ errors.append(
+ f"{document.relative_to(ROOT)}:{line}: missing local link target {raw!r}"
+ )
+ if errors:
+ print("\n".join(errors))
+ return 1
+ print(f"markdown links: {checked} local targets passed")
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/scripts/context_pack_eval.py b/scripts/context_pack_eval.py
index 08ac2a66..359abf3d 100644
--- a/scripts/context_pack_eval.py
+++ b/scripts/context_pack_eval.py
@@ -1352,6 +1352,11 @@ def main() -> None:
parser.add_argument("--vault-path", type=Path, help="Optional vault path. Defaults beside the SQLite database.")
parser.add_argument("--user-id", default=USER_ID)
parser.add_argument("--report-only", action="store_true", help="Print metrics without failing on regressions.")
+ parser.add_argument(
+ "--json",
+ action="store_true",
+ help="Print the complete per-case result instead of the concise default summary.",
+ )
args = parser.parse_args()
if args.db_path:
@@ -1364,9 +1369,31 @@ def main() -> None:
tmp = Path(tempfile.mkdtemp(prefix="context-pack-eval-"))
result = run_context_pack_eval(tmp / "context-pack-eval.sqlite", tmp / "Cortex.vault", args.user_id)
- print(json.dumps(result, indent=2, sort_keys=True))
-
failures = check_context_pack_thresholds(result)
+ if args.json or failures:
+ print(json.dumps(result, indent=2, sort_keys=True))
+ else:
+ metrics = result["metrics"]
+ replay_metrics = result["session_replay"]["metrics"]
+ print(
+ json.dumps(
+ {
+ "status": "ok",
+ "checks": result["counts"]["total_checks"],
+ "profiles": result.get("profile_names", []),
+ "ndcg@k": metrics.get("ndcg@k"),
+ "mrr": metrics.get("mrr"),
+ "citation_coverage": metrics.get("citation_coverage"),
+ "no_leak": metrics.get("no_leak"),
+ "budget_adherence": metrics.get("budget_adherence"),
+ "session_turns": replay_metrics.get("turn_count"),
+ "session_delta_token_ratio": replay_metrics.get("token_savings_ratio"),
+ "note": "Use --json for per-case and session-replay diagnostics.",
+ },
+ indent=2,
+ sort_keys=True,
+ )
+ )
if failures and not args.report_only:
print("\nCONTEXT PACK GATE FAILED:", file=sys.stderr)
for failure in failures:
diff --git a/scripts/dev_backend.sh b/scripts/dev_backend.sh
index 0fcc5758..ec4ac492 100755
--- a/scripts/dev_backend.sh
+++ b/scripts/dev_backend.sh
@@ -1,10 +1,68 @@
#!/usr/bin/env bash
set -euo pipefail
-cd "$(dirname "$0")/../backend"
+ROOT="$(cd "$(dirname "$0")/.." && pwd)"
+cd "$ROOT/backend"
export CORTEX_VAULT_PATH="${CORTEX_VAULT_PATH:-./data/Cortex.vault}"
export CORTEX_DB_PATH="${CORTEX_DB_PATH:-$CORTEX_VAULT_PATH/index.sqlite}"
export CORTEX_API_KEY="${CORTEX_API_KEY:-dev-local-key}"
export CORTEX_ALLOW_INSECURE_DEV_TOKEN="${CORTEX_ALLOW_INSECURE_DEV_TOKEN:-1}"
export CORTEX_PORT="${CORTEX_PORT:-8766}"
-python3 -m uvicorn app.main:app --reload --host 127.0.0.1 --port "$CORTEX_PORT"
+
+if [[ -n "${CORTEX_PYTHON:-}" ]]; then
+ PYTHON_BIN="$CORTEX_PYTHON"
+elif [[ -x "$ROOT/.venv/bin/python" ]]; then
+ PYTHON_BIN="$ROOT/.venv/bin/python"
+elif command -v python3.12 >/dev/null 2>&1; then
+ PYTHON_BIN="$(command -v python3.12)"
+else
+ echo "Cortex requires Python 3.12. Run 'make setup' first." >&2
+ exit 1
+fi
+
+VERSION="$("$PYTHON_BIN" -c 'import sys; print(f"{sys.version_info.major}.{sys.version_info.minor}")')"
+if [[ "$VERSION" != "3.12" ]]; then
+ echo "Cortex requires Python 3.12; '$PYTHON_BIN' reports Python $VERSION. Run 'make setup'." >&2
+ exit 1
+fi
+
+if FREE_PORT="$("$PYTHON_BIN" - "$CORTEX_PORT" <<'PY'
+import socket
+import sys
+
+try:
+ requested = int(sys.argv[1])
+ if not 1 <= requested <= 65535:
+ raise ValueError
+except ValueError:
+ print(f"CORTEX_PORT must be an integer from 1 to 65535; received {sys.argv[1]!r}.", file=sys.stderr)
+ raise SystemExit(2)
+
+with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as probe:
+ try:
+ probe.bind(("127.0.0.1", requested))
+ except OSError:
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as fallback:
+ fallback.bind(("127.0.0.1", 0))
+ print(fallback.getsockname()[1])
+ raise SystemExit(1)
+PY
+)"; then
+ :
+else
+ STATUS=$?
+ if [[ "$STATUS" -eq 1 ]]; then
+ cat >&2 < int:
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
+ sock.bind(("127.0.0.1", 0))
+ return int(sock.getsockname()[1])
+
+
+def _run_example(path: str, env: dict[str, str], *arguments: str) -> str:
+ completed = subprocess.run(
+ [sys.executable, path, *arguments],
+ cwd=ROOT,
+ env=env,
+ check=True,
+ capture_output=True,
+ text=True,
+ timeout=30,
+ )
+ return completed.stdout
+
+
+def _get_json(url: str, token: str) -> dict:
+ request = Request(
+ url,
+ headers={
+ "Accept": "application/json",
+ "Authorization": f"Bearer {token}",
+ },
+ )
+ with urlopen(request, timeout=5) as response: # noqa: S310 - loopback test server
+ return json.loads(response.read().decode("utf-8"))
+
+
+def _contains_demo_source(value: object) -> bool:
+ if isinstance(value, dict):
+ if value.get("source") == "cortex-demo":
+ return True
+ if "example.invalid/cortex-demo/" in str(value.get("source_url") or ""):
+ return True
+ return any(_contains_demo_source(item) for item in value.values())
+ if isinstance(value, list):
+ return any(_contains_demo_source(item) for item in value)
+ return False
+
+
+def _workflow_sections(output: str) -> dict[str, object]:
+ sections: dict[str, object] = {}
+ for chunk in output.split("\n## ")[1:]:
+ label, raw = chunk.split("\n", 1)
+ sections[label.strip()] = json.loads(raw.strip())
+ return sections
+
+
+def _print_quickstart_result(base_url: str, token: str) -> None:
+ query = "When does Project Atlas ship?"
+ answer = _get_json(
+ f"{base_url}/v1/ask?{urlencode({'query': query, 'limit': 5})}",
+ token,
+ )
+ if answer.get("status") != "cited" or not answer.get("citations"):
+ raise AssertionError(f"quickstart Ask did not return a cited answer: {answer}")
+ citation = answer["citations"][0]
+ print()
+ print("Cortex is working.")
+ print(" [ok] isolated loopback server started")
+ print(" [ok] 3 synthetic Project Atlas captures loaded")
+ print(" [ok] Ask returned cited evidence")
+ print()
+ print(f"Question: {query}")
+ print(f"Answer: {answer.get('answer', '').strip()}")
+ print(
+ "Citation: "
+ f"{citation.get('source_url') or citation.get('source') or citation.get('memory_id')}"
+ )
+ print()
+ print("The temporary vault has been removed; no personal Cortex data was read.")
+ print(
+ "Next: CORTEX_AUTO_APPROVE_CAPTURES=1 make run"
+ " # persistent dev server + http://127.0.0.1:8766/docs"
+ )
+
+
+def main(argv: list[str] | None = None) -> int:
+ parser = argparse.ArgumentParser(
+ description="Exercise public Cortex examples against an isolated local server."
+ )
+ parser.add_argument(
+ "--quickstart",
+ action="store_true",
+ help="Run only the synthetic seed + cited-answer path and print human-readable output.",
+ )
+ parser.add_argument(
+ "--runtime",
+ choices=("fastapi", "standalone"),
+ default="fastapi",
+ help="Server implementation to test. 'standalone' is the runtime shipped in the macOS app.",
+ )
+ args = parser.parse_args(argv)
+ port = _available_loopback_port()
+ base_url = f"http://127.0.0.1:{port}"
+ with tempfile.TemporaryDirectory(prefix="cortex-examples-") as temp:
+ vault = Path(temp) / "vault"
+ env = dict(os.environ)
+ env.update(
+ {
+ "CORTEX_ALLOW_INSECURE_DEV_TOKEN": "1",
+ "CORTEX_ALLOW_DEMO_SEED": "0",
+ "CORTEX_API_KEY": "dev-local-key",
+ "CORTEX_AUTO_APPROVE_CAPTURES": "1",
+ "CORTEX_BASE_URL": base_url,
+ "CORTEX_DB_PATH": str(vault / "index.sqlite"),
+ "CORTEX_VAULT_PATH": str(vault),
+ "PYTHONPATH": os.pathsep.join(
+ filter(
+ None,
+ [str(ROOT / "sdk" / "python"), env.get("PYTHONPATH", "")],
+ )
+ ),
+ }
+ )
+ if args.runtime == "standalone":
+ server_command = [
+ sys.executable,
+ "-m",
+ "backend.app.standalone_server",
+ "--host",
+ "127.0.0.1",
+ "--port",
+ str(port),
+ ]
+ else:
+ server_command = [
+ sys.executable,
+ "-m",
+ "uvicorn",
+ "backend.app.main:app",
+ "--host",
+ "127.0.0.1",
+ "--port",
+ str(port),
+ "--log-level",
+ "warning",
+ ]
+ server = subprocess.Popen(
+ server_command,
+ cwd=ROOT,
+ env=env,
+ stdout=subprocess.DEVNULL,
+ stderr=subprocess.DEVNULL,
+ )
+ try:
+ deadline = time.monotonic() + 15
+ while True:
+ if server.poll() is not None:
+ raise RuntimeError(f"example server exited with status {server.returncode}")
+ try:
+ with urlopen(f"{base_url}/health", timeout=0.5) as response: # noqa: S310
+ if response.status == 200:
+ break
+ except URLError:
+ if time.monotonic() >= deadline:
+ raise RuntimeError("example server did not become healthy in 15 seconds")
+ time.sleep(0.1)
+
+ unsafe_seed_env = {**env, "CORTEX_API_KEY": "cxa_not_for_demo_seeding"}
+ unsafe_seed = subprocess.run(
+ [sys.executable, "examples/seed_demo.py"],
+ cwd=ROOT,
+ env=unsafe_seed_env,
+ check=False,
+ capture_output=True,
+ text=True,
+ timeout=10,
+ )
+ if unsafe_seed.returncode != 2 or "requires the canonical dev-local-key" not in unsafe_seed.stderr:
+ raise AssertionError("seed_demo.py did not reject a non-development token")
+
+ seed = _run_example("examples/seed_demo.py", env)
+ if "done: 3 deterministic synthetic captures are ready" not in seed:
+ raise AssertionError("seed_demo.py did not report all fixture captures")
+ if args.quickstart:
+ _print_quickstart_result(base_url, "dev-local-key")
+ return 0
+ first_stats = _get_json(f"{base_url}/v1/stats", "dev-local-key")
+ second_seed = _run_example("examples/seed_demo.py", env)
+ if "done: 3 deterministic synthetic captures are ready" not in second_seed:
+ raise AssertionError("seed_demo.py was not repeatable")
+ second_stats = _get_json(f"{base_url}/v1/stats", "dev-local-key")
+ stable_counts = ("captures", "memories", "decisions", "tasks", "entities", "edges")
+ if any(first_stats.get(key) != second_stats.get(key) for key in stable_counts):
+ raise AssertionError(
+ "seed_demo.py changed stored record counts when run a second time: "
+ f"{first_stats} -> {second_stats}"
+ )
+
+ search = json.loads(_run_example("examples/minimal_search.py", env, "--json"))
+ results = search.get("results", [])
+ if not results or not _contains_demo_source(search):
+ raise AssertionError("minimal_search.py did not return the cited demo memory")
+
+ workflow = _workflow_sections(_run_example("examples/memory_workflow.py", env))
+ expected_sections = {"Search", "Cited answer or abstention", "Task context"}
+ if set(workflow) != expected_sections:
+ raise AssertionError(
+ f"memory_workflow.py returned {sorted(workflow)}, expected {sorted(expected_sections)}"
+ )
+ answer = workflow["Cited answer or abstention"]
+ context = workflow["Task context"]
+ if not isinstance(answer, dict) or answer.get("status") != "cited":
+ raise AssertionError("memory_workflow.py did not produce a cited answer")
+ if not _contains_demo_source(workflow["Search"]):
+ raise AssertionError("memory_workflow.py search omitted the demo source")
+ if not answer.get("citations") or not _contains_demo_source(answer):
+ raise AssertionError("memory_workflow.py answer omitted source-bearing citations")
+ if not isinstance(context, dict) or not context.get("citations"):
+ raise AssertionError("memory_workflow.py context omitted citations")
+ if not _contains_demo_source(context):
+ raise AssertionError("memory_workflow.py context omitted the demo source")
+
+ packs = json.loads(_run_example("examples/multi_agent_context.py", env))
+ if set(packs) != {"planner", "researcher", "writer"}:
+ raise AssertionError("multi_agent_context.py did not return all three role packs")
+ for role, pack in packs.items():
+ if not isinstance(pack, dict) or not pack.get("citations"):
+ raise AssertionError(f"multi_agent_context.py {role} pack omitted citations")
+ if not _contains_demo_source(pack):
+ raise AssertionError(
+ f"multi_agent_context.py {role} pack omitted the demo source"
+ )
+
+ tools = json.loads(_run_example("examples/tool_catalog.py", env))
+ if not tools or not all(item.get("name") for item in tools):
+ raise AssertionError("tool_catalog.py did not return named tools")
+ finally:
+ server.terminate()
+ try:
+ server.wait(timeout=5)
+ except subprocess.TimeoutExpired:
+ server.kill()
+ server.wait(timeout=5)
+
+ print(f"examples smoke: all public Python examples passed ({args.runtime} runtime)")
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/scripts/ops_readiness_check.py b/scripts/ops_readiness_check.py
index 00499faf..c837bcff 100644
--- a/scripts/ops_readiness_check.py
+++ b/scripts/ops_readiness_check.py
@@ -619,7 +619,16 @@ def main() -> None:
{"skipped": True, "reason": "local-dmg-only"},
)
else:
- site_manifest_result = run_command(root, [sys.executable, "scripts/validate_update_manifest.py", "site/downloads/latest.json"], timeout=60)
+ site_manifest_result = run_command(
+ root,
+ [
+ sys.executable,
+ "scripts/validate_update_manifest.py",
+ "--allow-remote-artifacts",
+ "site/downloads/latest.json",
+ ],
+ timeout=60,
+ )
add_check(checks, "site_update_manifest", site_manifest_result["ok"], "Site update feed validates.", site_manifest_result)
strict_package_artifacts = args.include_package or args.require_package_artifacts or release_dir_arg is not None
diff --git a/scripts/rerank_eval.py b/scripts/rerank_eval.py
index f7a62bb0..1c8656dc 100644
--- a/scripts/rerank_eval.py
+++ b/scripts/rerank_eval.py
@@ -206,22 +206,16 @@ def main(argv: list[str] | None = None) -> int:
),
)
args = parser.parse_args(argv)
- provider = embedding_status().get("provider")
- if provider != "model2vec":
+ configured_provider = embedding_status().get("provider")
+ # embedding_status reports configuration, not whether model loading
+ # succeeded. Probe the active implementation before deciding run/skip.
+ active_provider = embed_text_result("rerank eval availability probe").provider
+ if active_provider != "model2vec":
if args.forbid_skip:
- print(json.dumps({"status": "failed", "reason": f"--forbid-skip: model2vec unavailable (provider={provider}); refusing to skip the semantic-retrieval gate"}, indent=2))
+ print(json.dumps({"status": "failed", "reason": f"--forbid-skip: model2vec unavailable (configured={configured_provider}, active={active_provider}); refusing to skip the semantic-retrieval gate"}, indent=2))
return 1
- print(json.dumps({"status": "skipped", "reason": f"model2vec unavailable (provider={provider})"}, indent=2))
+ print(json.dumps({"status": "skipped", "reason": f"model2vec unavailable (configured={configured_provider}, active={active_provider})"}, indent=2))
return 0
- if args.forbid_skip:
- # embedding_status() reports the CONFIGURED provider; the runtime silently degrades to the
- # keyword-hash embedder when the model can't actually load. Under --forbid-skip, prove the
- # ACTIVE provider is really model2vec before trusting the eval — hash could still clear the
- # keyword-friendly floor and paint this gate green without any real semantics.
- active = embed_text_result("rerank eval demo-integrity probe").provider
- if active != "model2vec":
- print(json.dumps({"status": "failed", "reason": f"--forbid-skip: model2vec configured but the active embedder degraded to '{active}' (model failed to load); the eval would silently measure hash embeddings"}, indent=2))
- return 1
with tempfile.TemporaryDirectory() as tmp:
summary = run_rerank_eval(Path(tmp) / "cortex.db", Path(tmp) / "vault")
print(json.dumps(summary, indent=2))
diff --git a/scripts/retrieval_eval.py b/scripts/retrieval_eval.py
index 336a983f..3e39f743 100644
--- a/scripts/retrieval_eval.py
+++ b/scripts/retrieval_eval.py
@@ -5674,6 +5674,11 @@ def main() -> None:
action="store_true",
help="Print metrics without failing on threshold regressions (local inspection).",
)
+ parser.add_argument(
+ "--json",
+ action="store_true",
+ help="Print the complete per-case result instead of the concise default summary.",
+ )
args = parser.parse_args()
if args.db_path:
@@ -5683,10 +5688,29 @@ def main() -> None:
root = Path(tmp)
result = run_retrieval_eval(root / "retrieval-eval.sqlite", root / "Cortex.vault", args.user_id)
- print(json.dumps(result, indent=2, sort_keys=True))
-
failures = check_retrieval_metric_thresholds(result)
failures.extend(check_relevance_monotonicity(result))
+ if args.json or failures:
+ print(json.dumps(result, indent=2, sort_keys=True))
+ else:
+ overall = result["metrics"]["overall"]
+ monotonicity = result["relevance_monotonicity"]
+ print(
+ json.dumps(
+ {
+ "status": result.get("status"),
+ "retrieval_mode": "deterministic hash/FTS regression path",
+ "cases": overall.get("case_count"),
+ "top1_accuracy": overall.get("top1_accuracy"),
+ "recall@3": overall.get("recall@3"),
+ "precision@3": overall.get("precision@3"),
+ "pairwise_concordance": monotonicity.get("pairwise_concordance"),
+ "note": "Use --json for per-case diagnostics.",
+ },
+ indent=2,
+ sort_keys=True,
+ )
+ )
if failures and not args.report_only:
print("\nRETRIEVAL QUALITY GATE FAILED:", file=sys.stderr)
for failure in failures:
diff --git a/scripts/validate_update_manifest.py b/scripts/validate_update_manifest.py
index 68f06775..eb8a0f6c 100755
--- a/scripts/validate_update_manifest.py
+++ b/scripts/validate_update_manifest.py
@@ -37,7 +37,7 @@ def artifact_path(root: Path, filename: str, url: str) -> Path:
return root / filename
-def validate(manifest_path: Path) -> dict:
+def validate(manifest_path: Path, *, allow_remote_artifacts: bool = False) -> dict:
payload = json.loads(manifest_path.read_text(encoding="utf-8"))
missing = sorted(REQUIRED_TOP_LEVEL - set(payload))
if missing:
@@ -62,8 +62,18 @@ def validate(manifest_path: Path) -> dict:
if kind not in {"dmg", "zip", "obsidian-plugin"}:
raise ValueError(f"unsupported artifact kind: {kind}")
seen_kinds.add(kind)
- path = artifact_path(root, str(artifact["filename"]), str(artifact["url"]))
+ url = str(artifact["url"])
+ path = artifact_path(root, str(artifact["filename"]), url)
if not path.exists():
+ parsed = urlparse(url)
+ if allow_remote_artifacts and parsed.scheme == "https":
+ size = int(artifact["size_bytes"])
+ digest = str(artifact["sha256"]).lower()
+ if size <= 0:
+ raise ValueError(f"size_bytes must be positive for remote artifact: {artifact['filename']}")
+ if len(digest) != 64 or any(char not in "0123456789abcdef" for char in digest):
+ raise ValueError(f"sha256 must be a 64-character hexadecimal digest: {artifact['filename']}")
+ continue
raise FileNotFoundError(f"artifact not found: {path}")
size = path.stat().st_size
if size != int(artifact["size_bytes"]):
@@ -80,8 +90,13 @@ def validate(manifest_path: Path) -> dict:
def main() -> None:
parser = argparse.ArgumentParser(description="Validate a Cortex update manifest against local release artifacts.")
parser.add_argument("manifest", type=Path)
+ parser.add_argument(
+ "--allow-remote-artifacts",
+ action="store_true",
+ help="Allow absent artifacts only when their manifest URL uses HTTPS; schema, size, and digest metadata remain required.",
+ )
args = parser.parse_args()
- payload = validate(args.manifest)
+ payload = validate(args.manifest, allow_remote_artifacts=args.allow_remote_artifacts)
print(json.dumps({"status": "ok", "version": payload["version"], "build": payload["build"], "artifacts": len(payload["artifacts"])}, indent=2))
diff --git a/sdk/PUBLISHING.md b/sdk/PUBLISHING.md
index 52e29765..081861e6 100644
--- a/sdk/PUBLISHING.md
+++ b/sdk/PUBLISHING.md
@@ -22,9 +22,10 @@ that has changed:
The original placeholders (`cortex-client` on PyPI, `@cortex/client` on npm) were both
**already taken by unrelated parties** — `@cortex` and `@cortex-memory` are both claimed npm
-scopes owned by other accounts. `@doppl-tech` matches this repo's real GitHub org
-(`github.com/doppl-tech`), so it doubles as a trust signal for anyone verifying the package's
-provenance.
+scopes owned by other accounts. `@doppl-tech` is the project's package and signed-release
+namespace. Source provenance points to
+[`trace-cortex/cortex-app`](https://github.com/trace-cortex/cortex-app), while binary artifacts
+are published from `doppl-tech/releases`.
## Python — PyPI (`doppl-cortex-client`)
diff --git a/sdk/README.md b/sdk/README.md
index 2ffab6bf..9224213b 100644
--- a/sdk/README.md
+++ b/sdk/README.md
@@ -10,6 +10,11 @@ catalog.
| [Python](./python/) | `doppl-cortex-client` | stdlib `urllib` | none (Python 3.9+) |
| [TypeScript](./typescript/) | `@doppl-tech/cortex-client` | `fetch` | none (Node 18+ / browser) |
+> **Distribution status:** both clients are tested source packages in this
+> repository. Registry names are reserved/planned; use the editable/source
+> instructions in each SDK README until a published release is explicitly
+> documented.
+
Both mirror the same method surface:
| Method (py / ts) | Endpoint | Purpose |
@@ -18,7 +23,7 @@ Both mirror the same method surface:
| `openai_tools` / `openaiTools` | `GET /v1/tools/schema?format=openai` | OpenAI function-calling `tools` array. |
| `anthropic_tools` / `anthropicTools` | `GET /v1/tools/schema?format=anthropic` | Anthropic `tools` array. |
| `call_tool` / `callTool` | `POST /v1/tools/call` | Invoke any tool by name; returns its `result`. |
-| `context` | `POST /v1/context` | Token-budgeted, cited working-context pack. |
+| `context` | `POST /v1/context` | Token-budgeted, cited working-context pack; `sector` isolates while `project` is a ranking hint. |
| `search` | `GET /v1/search` | Search memory (`top_k` → server `limit`). |
| `ask` | `GET /v1/ask` | Cited answer or explicit abstention. |
@@ -28,10 +33,13 @@ Cortex runs **on the user's machine**. Both clients default to `http://127.0.0.1
(loopback) — the address the macOS app serves on. Nothing leaves the device unless you
point `base_url` / `baseUrl` elsewhere.
-Authentication is a bearer token (`Authorization: Bearer `) with an optional
-`X-Cortex-User` header to select a user in multi-user deployments. The token's scopes
-determine which tools are allowed; the server enforces scopes on every call regardless
-of what the schema advertises, so a read-only token gets a read-only surface.
+Authentication uses a scoped `cxa_` REST bearer token
+(`Authorization: Bearer `) with an optional `X-Cortex-User` header to
+select a user in multi-user deployments. The token's scopes determine which
+tools are allowed; the server enforces scopes on every call regardless of what
+the schema advertises, so a read-only token gets a read-only surface. `cxm_`
+tokens are reserved for the `/mcp` transport and must not be used with these
+SDKs.
## Relationship to MCP
@@ -39,9 +47,9 @@ Cortex exposes its tools two ways over the **same catalog and the same scope
enforcement**:
- **MCP** (`POST /mcp`) — JSON-RPC (`initialize` / `tools/list` / `tools/call`), for MCP
- clients like Claude Desktop and Cursor.
+ clients like Claude Desktop and Cursor; authenticates with a `cxm_` token.
- **Universal HTTP** (`/v1/tools/*`) — plain HTTP for any function-calling app. This is
- what these SDKs use.
+ what these SDKs use; authenticates with a `cxa_` token.
So the two transports are interchangeable: a tool called over MCP and the same tool
called via `call_tool` / `callTool` run identical server-side logic and honor the same
diff --git a/sdk/python/README.md b/sdk/python/README.md
index 7030ba9a..c3a12d22 100644
--- a/sdk/python/README.md
+++ b/sdk/python/README.md
@@ -9,7 +9,8 @@ function-calling app (OpenAI, Anthropic, or a custom agent loop) can drive it fr
tool definition. This client wraps that HTTP surface.
- Default base URL: `http://127.0.0.1:8766` (local loopback — Cortex is local-first).
-- Auth: `Authorization: Bearer `, optional `X-Cortex-User` header.
+- Auth: `Authorization: Bearer `, optional `X-Cortex-User`
+ header. `cxm_` tokens are only for `/mcp`, not this SDK.
- Same routes work against the hosted plane — pass `base_url="https://api.signindoppl.com"`
and a token minted there. The hosted server (`backend/app/main.py`) mirrors the local
server's `/v1/tools/schema` and `/v1/tools/call` request/response shapes exactly, including
@@ -19,22 +20,33 @@ tool definition. This client wraps that HTTP surface.
Requires Python 3.9+.
+The SDK is currently supported from this repository; the PyPI name is reserved
+but not yet the installation path:
+
```bash
-pip install doppl-cortex-client # once published (see sdk/PUBLISHING.md)
-# or, for local development against this repo:
-cd sdk/python && pip install -e .
+# From the Cortex repository root:
+python -m pip install -e sdk/python
```
-The importable module is `cortex_client` regardless of the PyPI package name (`doppl-cortex-client`)
-— see the Quickstart below. Or just copy the `cortex_client/` folder into your project — it has no
-dependencies.
+`make setup` already performs this editable install for contributors. Once a
+registry release is documented, the command will be
+`pip install doppl-cortex-client`; do not assume that command works before then.
+The importable module is `cortex_client` regardless of the distribution name.
+You can also vendor the `cortex_client/` folder because it has no dependencies.
## Quickstart
+First start Cortex (`make run` from the repository root) or open the installed
+macOS app. The development server uses `dev-local-key`; an installed app exposes
+a scoped `cxa_` REST token under Connections & Privacy.
+
```python
from cortex_client import CortexClient, CortexError
-client = CortexClient(base_url="http://127.0.0.1:8766", token="ctx_your_token")
+client = CortexClient(
+ base_url="http://127.0.0.1:8766",
+ token="dev-local-key", # use a scoped cxa_ token with the installed app
+)
# Cited answer to a specific question (never an uncited guess).
print(client.ask("What database do we use?"))
@@ -45,6 +57,15 @@ print(client.search("release checklist", top_k=5))
# Token-budgeted working-context pack — call this before doing a task.
print(client.context("draft the release notes", intent="draft", token_budget=1500))
+# Portable SMP projection with multi-turn delta context and a project hint.
+print(client.context(
+ "plan the Project Atlas launch",
+ format="smp",
+ model="claude",
+ session_id="atlas-planning",
+ project="Atlas",
+))
+
# Any tool in the catalog, by name.
print(client.call_tool("get_person_map", {}))
@@ -54,6 +75,12 @@ except CortexError as exc:
print(exc.status, exc.detail) # e.g. 403 "Cortex API token requires destructive scope"
```
+`sector` is the hard corpus filter to use when memories must be isolated.
+`project` only helps Cortex rank and enrich entity context; it is **not** an
+authorization or isolation boundary. If `pin=True` is combined with
+`session_id`, that ID must come from the `start_agent_session` MCP tool. Pinning
+without a session ID is valid.
+
## Methods
| Method | Endpoint | Purpose |
@@ -62,13 +89,16 @@ except CortexError as exc:
| `openai_tools()` | `GET /v1/tools/schema?format=openai` | Convenience → OpenAI function-calling `tools` array. |
| `anthropic_tools()` | `GET /v1/tools/schema?format=anthropic` | Convenience → Anthropic `tools` array. |
| `call_tool(name, arguments=None)` | `POST /v1/tools/call` | Invoke any tool; returns its `result`. |
-| `context(task, intent=None, token_budget=2000, surface="agent")` | `POST /v1/context` | Cited working-context pack. |
+| `context(task, ..., format=None, model=None, session_id=None, pin=None, sector=None, project=None, as_of=None)` | `POST /v1/context` | Cited pack, SMP/Markdown projection, sector filter, project hint, and session deltas. |
| `search(query, top_k=8)` | `GET /v1/search` | Search memory. |
| `ask(query, top_k=8)` | `GET /v1/ask` | Cited answer / explicit abstention. |
Any non-2xx response raises `CortexError(status, detail)`, where `detail` is the
server's `{"detail": ...}` payload (a string or an object). Transport failures (server
-unreachable) raise `CortexError` with `status == 0`.
+unreachable) and invalid base URLs raise `CortexError` with `status == 0`. Base URLs
+must be absolute `http://` or `https://` URLs without embedded user information.
+Queries and fragments are rejected as well; put request parameters on SDK
+methods instead of the base URL.
## OpenAI function-calling
@@ -80,7 +110,7 @@ import json
from openai import OpenAI
from cortex_client import CortexClient
-cortex = CortexClient(token="ctx_your_token")
+cortex = CortexClient(token="cxa_your_token")
oai = OpenAI()
tools = cortex.openai_tools() # Cortex tool catalog as OpenAI function schemas
@@ -115,7 +145,7 @@ a `tool_result` block:
import anthropic
from cortex_client import CortexClient
-cortex = CortexClient(token="ctx_your_token")
+cortex = CortexClient(token="cxa_your_token")
client = anthropic.Anthropic()
tools = cortex.anthropic_tools()
@@ -150,5 +180,5 @@ print(resp.content)
The test suite stubs the SDK's one `urllib` call — it never touches the network:
```bash
-python3 -m pytest sdk/python/tests/test_client.py
+.venv/bin/python -m pytest sdk/python/tests/test_client.py
```
diff --git a/sdk/python/cortex_client/client.py b/sdk/python/cortex_client/client.py
index b9eca4d6..4f8320bd 100644
--- a/sdk/python/cortex_client/client.py
+++ b/sdk/python/cortex_client/client.py
@@ -15,8 +15,8 @@
import json
from typing import Any, Optional
from urllib.error import HTTPError, URLError
-from urllib.parse import urlencode
-from urllib.request import Request, urlopen
+from urllib.parse import urlencode, urljoin, urlsplit
+from urllib.request import HTTPRedirectHandler, Request, build_opener
__all__ = ["CortexClient", "CortexError"]
@@ -24,6 +24,51 @@
DEFAULT_TIMEOUT = 30.0
+def _origin(url: str) -> tuple[str, str, int] | None:
+ try:
+ parsed = urlsplit(url)
+ except ValueError:
+ return None
+ if (
+ parsed.scheme not in {"http", "https"}
+ or not parsed.hostname
+ or parsed.username is not None
+ or parsed.password is not None
+ ):
+ return None
+ try:
+ port = parsed.port
+ except ValueError:
+ return None
+ return parsed.scheme, parsed.hostname.lower(), port or (443 if parsed.scheme == "https" else 80)
+
+
+class _SameOriginRedirectHandler(HTTPRedirectHandler):
+ """Never forward a Cortex bearer token to another origin."""
+
+ def redirect_request(self, req, fp, code, msg, headers, newurl): # type: ignore[override]
+ resolved = urljoin(req.full_url, str(newurl or ""))
+ if _origin(req.full_url) != _origin(resolved):
+ raise HTTPError(
+ req.full_url,
+ code,
+ "cross-origin redirect blocked for credential-bearing request",
+ headers,
+ fp,
+ )
+ return super().redirect_request(req, fp, code, msg, headers, resolved)
+
+
+def _safe_urlopen(request: Request, *, timeout: float):
+ if _origin(request.full_url) is None:
+ raise CortexError(
+ 0,
+ "Invalid Cortex base URL: expected an absolute HTTP(S) URL "
+ "without user info, a query, or a fragment",
+ )
+ return build_opener(_SameOriginRedirectHandler()).open(request, timeout=timeout)
+
+
class CortexError(Exception):
"""Raised when the Cortex server returns a non-2xx response or is unreachable.
@@ -54,7 +99,7 @@ class CortexClient:
timeout: Per-request socket timeout in seconds.
Example:
- >>> client = CortexClient(token="ctx_...")
+ >>> client = CortexClient(token="cxa_...")
>>> answer = client.ask("What database do we use?")
>>> hits = client.search("release checklist", top_k=5)
"""
@@ -66,7 +111,23 @@ def __init__(
user: Optional[str] = None,
timeout: float = DEFAULT_TIMEOUT,
) -> None:
- self.base_url = base_url.rstrip("/")
+ normalized_base_url = base_url.strip().rstrip("/")
+ try:
+ parsed_base_url = urlsplit(normalized_base_url)
+ except ValueError:
+ parsed_base_url = None
+ if (
+ _origin(normalized_base_url) is None
+ or parsed_base_url is None
+ or bool(parsed_base_url.query)
+ or bool(parsed_base_url.fragment)
+ ):
+ raise CortexError(
+ 0,
+ "Invalid Cortex base URL: expected an absolute HTTP(S) URL "
+ "without user info, a query, or a fragment",
+ )
+ self.base_url = normalized_base_url
self.token = token
self.user = user
self.timeout = timeout
@@ -114,7 +175,7 @@ def _request(
headers=self._headers(json_body=body is not None),
)
try:
- with urlopen(request, timeout=self.timeout) as response:
+ with _safe_urlopen(request, timeout=self.timeout) as response:
raw = response.read()
except HTTPError as exc:
raise self._error_from_http(exc) from exc
@@ -212,6 +273,14 @@ def context(
intent: Optional[str] = None,
token_budget: int = 2000,
surface: str = "agent",
+ *,
+ format: Optional[str] = None,
+ model: Optional[str] = None,
+ session_id: Optional[str] = None,
+ pin: Optional[bool] = None,
+ sector: Optional[str] = None,
+ project: Optional[str] = None,
+ as_of: Optional[str] = None,
) -> Any:
"""Build a token-budgeted, cited working-context pack for a task.
@@ -223,20 +292,37 @@ def context(
``recall``).
token_budget: Approximate token budget for the assembled pack.
surface: Which tool/agent you are (e.g. ``cursor``, ``claude``, ``agent``).
+ format: Optional response projection: ``json``, ``markdown``, or ``smp``.
+ model: Optional target-model profile used for pack adaptation.
+ session_id: Optional multi-turn session identifier for delta packs. When
+ combined with ``pin=True``, this must be an ``asess_...`` id returned
+ by the ``start_agent_session`` MCP tool.
+ pin: Whether to persist an immutable, content-addressed copy of the pack.
+ sector: Optional hard memory-sector filter; use this for corpus isolation.
+ project: Optional entity-ranking hint. This is not an access-control or
+ isolation boundary; use ``sector`` for isolation.
+ as_of: Optional ISO 8601 historical cutoff.
Returns:
The assembled context pack (JSON object).
"""
- return self._request(
- "POST",
- "/v1/context",
- body={
- "task": task,
- "intent": intent,
- "token_budget": token_budget,
- "surface": surface,
- },
- )
+ body: dict[str, Any] = {
+ "task": task,
+ "intent": intent,
+ "token_budget": token_budget,
+ "surface": surface,
+ }
+ optional_fields = {
+ "format": format,
+ "model": model,
+ "session_id": session_id,
+ "pin": pin,
+ "sector": sector,
+ "project": project,
+ "as_of": as_of,
+ }
+ body.update({key: value for key, value in optional_fields.items() if value is not None})
+ return self._request("POST", "/v1/context", body=body)
def search(self, query: str, top_k: int = 8) -> Any:
"""Search Cortex memory and return results with retrieval diagnostics.
diff --git a/sdk/python/pyproject.toml b/sdk/python/pyproject.toml
index 5f261432..306e28c5 100644
--- a/sdk/python/pyproject.toml
+++ b/sdk/python/pyproject.toml
@@ -26,8 +26,8 @@ test = ["pytest>=7.0"]
[project.urls]
Homepage = "https://trydoppl.com"
-Repository = "https://github.com/doppl-tech/cortex-app"
-Documentation = "https://github.com/doppl-tech/cortex-app/blob/main/docs/EXTERNAL_INTEGRATIONS.md"
+Repository = "https://github.com/trace-cortex/cortex-app"
+Documentation = "https://github.com/trace-cortex/cortex-app/blob/main/docs/EXTERNAL_INTEGRATIONS.md"
[tool.setuptools.packages.find]
where = ["."]
diff --git a/sdk/python/tests/test_client.py b/sdk/python/tests/test_client.py
index c021493d..052b99fa 100644
--- a/sdk/python/tests/test_client.py
+++ b/sdk/python/tests/test_client.py
@@ -1,7 +1,7 @@
"""Unit tests for the Cortex Python SDK.
-These tests DO NOT hit the network. They monkeypatch ``urllib.request.urlopen`` (the
-one call the SDK makes) so we can assert the exact URL, method, headers, and JSON body
+These tests DO NOT hit the network. They monkeypatch the SDK transport seam so we can
+assert the exact URL, method, headers, and JSON body
the client builds, and verify parsing of canned responses plus error raising.
Run: python3 -m pytest sdk/python/tests/test_client.py
@@ -14,6 +14,7 @@
import os
import sys
from urllib.error import HTTPError, URLError
+from urllib.request import Request
import pytest
@@ -57,7 +58,7 @@ def __call__(self, request, timeout=None):
@pytest.fixture
def recorder(monkeypatch):
rec = _Recorder()
- monkeypatch.setattr(client_module, "urlopen", rec)
+ monkeypatch.setattr(client_module, "_safe_urlopen", rec)
return rec
@@ -103,6 +104,25 @@ def test_base_url_trailing_slash_stripped(recorder):
assert recorder.request.full_url.startswith("http://127.0.0.1:8766/v1/search")
+@pytest.mark.parametrize(
+ "base_url",
+ [
+ "not-a-url",
+ "file:///tmp/cortex",
+ "https://user:password@api.signindoppl.com",
+ "http://127.0.0.1:not-a-port",
+ "https://[::1",
+ "https://api.signindoppl.com?tenant=other",
+ "https://api.signindoppl.com#fragment",
+ ],
+)
+def test_invalid_base_url_raises_documented_cortex_error(base_url):
+ with pytest.raises(CortexError) as excinfo:
+ CortexClient(base_url=base_url, token="secret")
+ assert excinfo.value.status == 0
+ assert "Invalid Cortex base URL" in str(excinfo.value.detail)
+
+
# -- search ----------------------------------------------------------------------------
def test_search_url_method_and_topk(recorder):
@@ -117,7 +137,7 @@ def test_search_url_method_and_topk(recorder):
def test_search_parses_canned_response(monkeypatch):
payload = {"query": "q", "results": [{"id": "m1", "content": "hi"}]}
rec = _Recorder(json.dumps(payload).encode("utf-8"))
- monkeypatch.setattr(client_module, "urlopen", rec)
+ monkeypatch.setattr(client_module, "_safe_urlopen", rec)
client = CortexClient(token="t")
result = client.search("q")
assert result == payload
@@ -160,12 +180,41 @@ def test_context_defaults(recorder):
assert body["surface"] == "agent"
+def test_context_forwards_cmp_session_and_scope_options(recorder):
+ client = CortexClient(token="t")
+ client.context(
+ "plan the Project Atlas launch",
+ intent="plan",
+ format="smp",
+ model="claude",
+ session_id="session-atlas",
+ pin=True,
+ sector="Project Atlas",
+ project="Atlas",
+ as_of="2026-07-30T00:00:00Z",
+ )
+ body = json.loads(recorder.request.data.decode("utf-8"))
+ assert body == {
+ "task": "plan the Project Atlas launch",
+ "intent": "plan",
+ "token_budget": 2000,
+ "surface": "agent",
+ "format": "smp",
+ "model": "claude",
+ "session_id": "session-atlas",
+ "pin": True,
+ "sector": "Project Atlas",
+ "project": "Atlas",
+ "as_of": "2026-07-30T00:00:00Z",
+ }
+
+
# -- call_tool -------------------------------------------------------------------------
def test_call_tool_body_and_unwrap(monkeypatch):
canned = {"tool": "search_memory", "result": {"results": [1, 2, 3]}}
rec = _Recorder(json.dumps(canned).encode("utf-8"))
- monkeypatch.setattr(client_module, "urlopen", rec)
+ monkeypatch.setattr(client_module, "_safe_urlopen", rec)
client = CortexClient(token="t")
result = client.call_tool("search_memory", {"query": "x", "top_k": 2})
# URL / method / body
@@ -189,7 +238,7 @@ def test_call_tool_defaults_arguments_to_empty(recorder):
def test_tools_schema_default_format_and_unwrap(monkeypatch):
schema = [{"type": "function", "function": {"name": "get_context"}}]
rec = _Recorder(json.dumps({"schema": schema}).encode("utf-8"))
- monkeypatch.setattr(client_module, "urlopen", rec)
+ monkeypatch.setattr(client_module, "_safe_urlopen", rec)
client = CortexClient(token="t")
result = client.tools_schema()
assert rec.request.get_method() == "GET"
@@ -199,7 +248,7 @@ def test_tools_schema_default_format_and_unwrap(monkeypatch):
def test_openai_tools_uses_openai_format(monkeypatch):
rec = _Recorder(json.dumps({"schema": []}).encode("utf-8"))
- monkeypatch.setattr(client_module, "urlopen", rec)
+ monkeypatch.setattr(client_module, "_safe_urlopen", rec)
client = CortexClient(token="t")
client.openai_tools()
assert "format=openai" in rec.request.full_url
@@ -207,7 +256,7 @@ def test_openai_tools_uses_openai_format(monkeypatch):
def test_anthropic_tools_uses_anthropic_format(monkeypatch):
rec = _Recorder(json.dumps({"schema": []}).encode("utf-8"))
- monkeypatch.setattr(client_module, "urlopen", rec)
+ monkeypatch.setattr(client_module, "_safe_urlopen", rec)
client = CortexClient(token="t")
client.anthropic_tools()
assert "format=anthropic" in rec.request.full_url
@@ -233,7 +282,7 @@ def _fake(request, timeout=None):
def test_403_raises_cortex_error_with_detail(monkeypatch):
monkeypatch.setattr(
client_module,
- "urlopen",
+ "_safe_urlopen",
_raise_http_error(403, "Cortex API token requires write scope"),
)
client = CortexClient(token="t")
@@ -246,7 +295,7 @@ def test_403_raises_cortex_error_with_detail(monkeypatch):
def test_401_raises_cortex_error(monkeypatch):
monkeypatch.setattr(
client_module,
- "urlopen",
+ "_safe_urlopen",
_raise_http_error(401, "Missing or invalid Cortex API token"),
)
client = CortexClient(token="")
@@ -258,7 +307,7 @@ def test_401_raises_cortex_error(monkeypatch):
def test_error_detail_can_be_object(monkeypatch):
detail = {"status": "needs_configuration", "hosted_readiness": {"status": "error"}}
- monkeypatch.setattr(client_module, "urlopen", _raise_http_error(503, detail))
+ monkeypatch.setattr(client_module, "_safe_urlopen", _raise_http_error(503, detail))
client = CortexClient(token="t")
with pytest.raises(CortexError) as excinfo:
client.ask("q")
@@ -270,7 +319,7 @@ def test_transport_error_raises_cortex_error_status_zero(monkeypatch):
def _fake(request, timeout=None):
raise URLError("Connection refused")
- monkeypatch.setattr(client_module, "urlopen", _fake)
+ monkeypatch.setattr(client_module, "_safe_urlopen", _fake)
client = CortexClient(token="t")
with pytest.raises(CortexError) as excinfo:
client.search("q")
@@ -278,5 +327,40 @@ def _fake(request, timeout=None):
assert "Connection refused" in str(excinfo.value.detail)
+def test_cross_origin_redirect_is_blocked_before_bearer_token_can_move():
+ handler = client_module._SameOriginRedirectHandler()
+ request = Request(
+ "https://api.signindoppl.com/v1/search",
+ headers={"Authorization": "Bearer secret"},
+ )
+ with pytest.raises(HTTPError, match="cross-origin redirect blocked"):
+ handler.redirect_request(
+ request,
+ io.BytesIO(),
+ 302,
+ "Found",
+ {},
+ "https://attacker.invalid/collect",
+ )
+
+
+def test_same_origin_relative_redirect_is_allowed():
+ handler = client_module._SameOriginRedirectHandler()
+ request = Request(
+ "https://api.signindoppl.com/v1/search",
+ headers={"Authorization": "Bearer secret"},
+ )
+ redirected = handler.redirect_request(
+ request,
+ io.BytesIO(),
+ 307,
+ "Temporary Redirect",
+ {},
+ "/v1/search-next",
+ )
+ assert redirected is not None
+ assert redirected.full_url == "https://api.signindoppl.com/v1/search-next"
+
+
if __name__ == "__main__":
raise SystemExit(pytest.main([__file__, "-v"]))
diff --git a/sdk/typescript/README.md b/sdk/typescript/README.md
index 42aad9b4..75612daa 100644
--- a/sdk/typescript/README.md
+++ b/sdk/typescript/README.md
@@ -9,7 +9,8 @@ function-calling app (OpenAI, Anthropic, or a custom agent loop) can drive it fr
tool definition. This client wraps that HTTP surface.
- Default base URL: `http://127.0.0.1:8766` (local loopback — Cortex is local-first).
-- Auth: `Authorization: Bearer `, optional `X-Cortex-User` header.
+- Auth: `Authorization: Bearer `, optional `X-Cortex-User`
+ header. `cxm_` tokens are only for `/mcp`, not this SDK.
- Same routes work against the hosted plane — set `baseUrl: "https://api.signindoppl.com"`
and use a token minted there. The hosted server (`backend/app/main.py`) mirrors the local
server's `/v1/tools/schema` and `/v1/tools/call` request/response shapes exactly, including
@@ -20,29 +21,34 @@ tool definition. This client wraps that HTTP surface.
Requires Node 18+ (for global `fetch` / `AbortController`).
-```bash
-npm install @doppl-tech/cortex-client # once published (see sdk/PUBLISHING.md)
-```
-
-### Build from source
+The npm scope is planned but not yet the supported installation path. Build and
+test the source package from this repository:
```bash
cd sdk/typescript
-npm install # dev-only: typescript, @types/node
+npm ci # dev-only: typescript, @types/node
npm run build # emits dist/index.js + dist/index.d.ts via tsc
npm test # compiles test/ + src/ and runs against a stubbed fetch (node:test)
```
-Or drop `src/index.ts` straight into your project.
+Until a registry release is documented, import `dist/index.js` from the checkout
+or vendor `src/index.ts`. Do not assume
+`npm install @doppl-tech/cortex-client` works before then.
## Quickstart
+First start Cortex (`make run` from the repository root) or open the installed
+macOS app. The development server uses `dev-local-key`; an installed app exposes
+a scoped `cxa_` REST token under Connections & Privacy.
+
```ts
-import { CortexClient, CortexError } from "@doppl-tech/cortex-client";
+// Registry release: import from "@doppl-tech/cortex-client".
+// Source checkout: import from "./sdk/typescript/dist/index.js".
+import { CortexClient, CortexError } from "./sdk/typescript/dist/index.js";
const cortex = new CortexClient({
baseUrl: "http://127.0.0.1:8766",
- token: "ctx_your_token",
+ token: "dev-local-key", // use a scoped cxa_ token with the installed app
});
// Cited answer to a specific question (never an uncited guess).
@@ -54,6 +60,14 @@ console.log(await cortex.search("release checklist", 5));
// Token-budgeted working-context pack — call this before doing a task.
console.log(await cortex.context("draft the release notes", { intent: "draft", tokenBudget: 1500 }));
+// Portable SMP projection with multi-turn delta context and a project hint.
+console.log(await cortex.context("plan the Project Atlas launch", {
+ format: "smp",
+ model: "claude",
+ sessionId: "atlas-planning",
+ project: "Atlas",
+}));
+
// Any tool in the catalog, by name.
console.log(await cortex.callTool("get_person_map", {}));
@@ -66,6 +80,12 @@ try {
}
```
+`sector` is the hard corpus filter to use when memories must be isolated.
+`project` only helps Cortex rank and enrich entity context; it is **not** an
+authorization or isolation boundary. If `pin: true` is combined with
+`sessionId`, that ID must come from the `start_agent_session` MCP tool. Pinning
+without a session ID is valid.
+
## Methods
| Method | Endpoint | Purpose |
@@ -74,13 +94,16 @@ try {
| `openaiTools()` | `GET /v1/tools/schema?format=openai` | Convenience → `OpenAITool[]`. |
| `anthropicTools()` | `GET /v1/tools/schema?format=anthropic` | Convenience → `AnthropicTool[]`. |
| `callTool(name, args = {})` | `POST /v1/tools/call` | Invoke any tool; returns its `result`. |
-| `context(task, options?)` | `POST /v1/context` | Cited working-context pack. |
+| `context(task, options?)` | `POST /v1/context` | Cited pack, SMP/Markdown projection, sector filter, project hint, and session deltas. |
| `search(query, topK = 8)` | `GET /v1/search` | Search memory. |
| `ask(query, topK = 8)` | `GET /v1/ask` | Cited answer / explicit abstention. |
Every method is `async`. Any non-2xx response rejects with `CortexError` carrying
`.status` and `.detail` (the server's `{"detail": ...}` payload — a string or an
object). Transport failures / timeouts reject with `CortexError` and `.status === 0`.
+Credential-bearing redirects are handled manually: GET/HEAD follows at most
+three same-origin redirects, while cross-origin redirects and redirects for
+body-bearing requests are rejected before the token can be replayed.
All methods accept a generic type parameter for the expected response shape, e.g.
`await cortex.search<{ results: Memory[] }>("q")`.
@@ -94,7 +117,7 @@ back through `cortex.callTool`:
import OpenAI from "openai";
import { CortexClient } from "@doppl-tech/cortex-client";
-const cortex = new CortexClient({ token: "ctx_your_token" });
+const cortex = new CortexClient({ token: "cxa_your_token" });
const oai = new OpenAI();
const tools = await cortex.openaiTools(); // Cortex catalog as OpenAI function schemas
@@ -126,7 +149,7 @@ input_schema }` shape. Route `tool_use` blocks back through `callTool` and reply
import Anthropic from "@anthropic-ai/sdk";
import { CortexClient } from "@doppl-tech/cortex-client";
-const cortex = new CortexClient({ token: "ctx_your_token" });
+const cortex = new CortexClient({ token: "cxa_your_token" });
const client = new Anthropic();
const tools = await cortex.anthropicTools();
diff --git a/sdk/typescript/package-lock.json b/sdk/typescript/package-lock.json
index 15c42748..7b88ff0f 100644
--- a/sdk/typescript/package-lock.json
+++ b/sdk/typescript/package-lock.json
@@ -1,11 +1,11 @@
{
- "name": "@cortex/client",
+ "name": "@doppl-tech/cortex-client",
"version": "0.1.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
- "name": "@cortex/client",
+ "name": "@doppl-tech/cortex-client",
"version": "0.1.0",
"license": "MIT",
"devDependencies": {
diff --git a/sdk/typescript/package.json b/sdk/typescript/package.json
index 06de3fc0..92ba28c7 100644
--- a/sdk/typescript/package.json
+++ b/sdk/typescript/package.json
@@ -36,11 +36,11 @@
"homepage": "https://trydoppl.com",
"repository": {
"type": "git",
- "url": "https://github.com/doppl-tech/cortex-app.git",
+ "url": "https://github.com/trace-cortex/cortex-app.git",
"directory": "sdk/typescript"
},
"bugs": {
- "url": "https://github.com/doppl-tech/cortex-app/issues"
+ "url": "https://github.com/trace-cortex/cortex-app/issues"
},
"publishConfig": {
"access": "public"
diff --git a/sdk/typescript/src/index.ts b/sdk/typescript/src/index.ts
index e5d0632b..d2049e9c 100644
--- a/sdk/typescript/src/index.ts
+++ b/sdk/typescript/src/index.ts
@@ -49,6 +49,26 @@ export interface ContextOptions {
tokenBudget?: number;
/** Which tool/agent you are (e.g. "cursor", "claude", "agent"). Default "agent". */
surface?: string;
+ /** Response projection. The SMP form is designed for portable agent memory. */
+ format?: "json" | "markdown" | "smp";
+ /** Optional target-model profile used for pack adaptation. */
+ model?: string;
+ /**
+ * Multi-turn session id used for delta context packs. With `pin: true`, this
+ * must be an `asess_...` id returned by the `start_agent_session` MCP tool.
+ */
+ sessionId?: string;
+ /** Persist an immutable, content-addressed copy of the assembled pack. */
+ pin?: boolean;
+ /** Optional hard memory-sector filter; use this for corpus isolation. */
+ sector?: string;
+ /**
+ * Optional entity-ranking hint. This is not an access-control or isolation
+ * boundary; use `sector` for isolation.
+ */
+ project?: string;
+ /** Optional ISO 8601 historical cutoff. */
+ asOf?: string;
}
/** Constructor options for {@link CortexClient}. */
@@ -67,6 +87,8 @@ export interface CortexClientOptions {
const DEFAULT_BASE_URL = "http://127.0.0.1:8766";
const DEFAULT_TIMEOUT_MS = 30_000;
+const MAX_SAME_ORIGIN_REDIRECTS = 3;
+const REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308]);
/**
* Raised when the Cortex server returns a non-2xx response or is unreachable.
@@ -103,7 +125,7 @@ function stringifyDetail(detail: unknown): string {
*
* @example
* ```ts
- * const cortex = new CortexClient({ token: "ctx_..." });
+ * const cortex = new CortexClient({ token: "cxa_..." });
* const answer = await cortex.ask("What database do we use?");
* const hits = await cortex.search("release checklist", 5);
* ```
@@ -116,7 +138,26 @@ export class CortexClient {
private readonly fetchImpl: typeof fetch;
constructor(options: CortexClientOptions = {}) {
- this.baseUrl = (options.baseUrl ?? DEFAULT_BASE_URL).replace(/\/+$/, "");
+ const candidateBaseUrl = options.baseUrl ?? DEFAULT_BASE_URL;
+ let parsedBaseUrl: URL;
+ try {
+ parsedBaseUrl = new URL(candidateBaseUrl);
+ } catch {
+ throw new CortexError(0, "Cortex baseUrl must be an absolute HTTP(S) URL");
+ }
+ if (
+ !["http:", "https:"].includes(parsedBaseUrl.protocol) ||
+ parsedBaseUrl.username ||
+ parsedBaseUrl.password ||
+ parsedBaseUrl.search ||
+ parsedBaseUrl.hash
+ ) {
+ throw new CortexError(
+ 0,
+ "Cortex baseUrl must be an absolute HTTP(S) URL without credentials, query, or fragment",
+ );
+ }
+ this.baseUrl = parsedBaseUrl.toString().replace(/\/+$/, "");
this.token = options.token ?? "";
this.user = options.user ?? null;
this.timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
@@ -166,27 +207,63 @@ export class CortexClient {
const timer = setTimeout(() => controller.abort(), this.timeoutMs);
let response: Response;
+ let currentUrl = url;
+ let redirectCount = 0;
try {
- response = await this.fetchImpl(url, {
- method,
- headers: this.headers(hasBody),
- body: hasBody ? JSON.stringify(options.body) : undefined,
- signal: controller.signal,
- });
+ for (;;) {
+ response = await this.fetchImpl(currentUrl, {
+ method,
+ headers: this.headers(hasBody),
+ body: hasBody ? JSON.stringify(options.body) : undefined,
+ // Keep redirect handling inside this client. That lets us prove that a
+ // bearer token is only replayed to the same origin.
+ redirect: "manual",
+ signal: controller.signal,
+ });
+ if (!REDIRECT_STATUSES.has(response.status)) break;
+
+ if (method !== "GET" && method !== "HEAD") {
+ throw new CortexError(
+ response.status,
+ `Redirect blocked for credential-bearing ${method} request`,
+ );
+ }
+ const location = response.headers.get("location");
+ if (!location) {
+ throw new CortexError(response.status, "Redirect response did not include a Location header");
+ }
+ if (redirectCount >= MAX_SAME_ORIGIN_REDIRECTS) {
+ throw new CortexError(response.status, "Too many Cortex API redirects (maximum 3)");
+ }
+
+ let redirectedUrl: URL;
+ try {
+ redirectedUrl = new URL(location, currentUrl);
+ } catch {
+ throw new CortexError(response.status, "Redirect response included an invalid Location URL");
+ }
+ if (redirectedUrl.origin !== new URL(currentUrl).origin) {
+ throw new CortexError(
+ response.status,
+ "Cross-origin redirect blocked for credential-bearing request",
+ );
+ }
+ currentUrl = redirectedUrl.toString();
+ redirectCount += 1;
+ }
+ const text = await response.text();
+ const parsed = parseMaybeJson(text);
+ if (!response.ok) {
+ throw new CortexError(response.status, errorDetail(parsed, text, response.statusText));
+ }
+ return parsed as T;
} catch (err) {
+ if (err instanceof CortexError) throw err;
const reason = err instanceof Error ? err.message : String(err);
throw new CortexError(0, `Could not reach Cortex at ${this.baseUrl}: ${reason}`);
} finally {
clearTimeout(timer);
}
-
- const text = await response.text();
- const parsed = parseMaybeJson(text);
-
- if (!response.ok) {
- throw new CortexError(response.status, errorDetail(parsed, text, response.statusText));
- }
- return parsed as T;
}
// -- tool catalog --------------------------------------------------------------------
@@ -249,13 +326,21 @@ export class CortexClient {
* Call this first before doing work for the user.
*/
async context(task: string, options: ContextOptions = {}): Promise {
+ const body: Record = {
+ task,
+ intent: options.intent ?? null,
+ token_budget: options.tokenBudget ?? 2000,
+ surface: options.surface ?? "agent",
+ };
+ if (options.format !== undefined) body.format = options.format;
+ if (options.model !== undefined) body.model = options.model;
+ if (options.sessionId !== undefined) body.session_id = options.sessionId;
+ if (options.pin !== undefined) body.pin = options.pin;
+ if (options.sector !== undefined) body.sector = options.sector;
+ if (options.project !== undefined) body.project = options.project;
+ if (options.asOf !== undefined) body.as_of = options.asOf;
return this.request("POST", "/v1/context", {
- body: {
- task,
- intent: options.intent ?? null,
- token_budget: options.tokenBudget ?? 2000,
- surface: options.surface ?? "agent",
- },
+ body,
});
}
diff --git a/sdk/typescript/test/client.test.ts b/sdk/typescript/test/client.test.ts
index fbb10119..3babb555 100644
--- a/sdk/typescript/test/client.test.ts
+++ b/sdk/typescript/test/client.test.ts
@@ -20,10 +20,18 @@ interface RecordedRequest {
method: string;
headers: Record;
body: string | undefined;
+ redirect: RequestRedirect | undefined;
}
/** A minimal fetch stub: records every call and returns a queued response (FIFO). */
-function makeFetchStub(responses: Array<{ status: number; body: unknown; ok?: boolean }>) {
+function makeFetchStub(
+ responses: Array<{
+ status: number;
+ body: unknown;
+ ok?: boolean;
+ headers?: Record;
+ }>,
+) {
const calls: RecordedRequest[] = [];
let index = 0;
const fetchStub = (async (input: RequestInfo | URL, init?: RequestInit) => {
@@ -39,16 +47,23 @@ function makeFetchStub(responses: Array<{ status: number; body: unknown; ok?: bo
method: init?.method ?? "GET",
headers,
body: typeof init?.body === "string" ? init.body : undefined,
+ redirect: init?.redirect,
});
const queued = responses[Math.min(index, responses.length - 1)];
index += 1;
const status = queued.status;
const ok = queued.ok ?? (status >= 200 && status < 300);
const text = typeof queued.body === "string" ? queued.body : JSON.stringify(queued.body);
+ const responseHeaders = new Map(
+ Object.entries(queued.headers ?? {}).map(([key, value]) => [key.toLowerCase(), value]),
+ );
return {
ok,
status,
statusText: ok ? "OK" : "Error",
+ headers: {
+ get: (name: string) => responseHeaders.get(name.toLowerCase()) ?? null,
+ },
text: async () => text,
} as unknown as Response;
}) as typeof fetch;
@@ -61,15 +76,16 @@ test("toolsSchema issues GET /v1/tools/schema?format= with bearer auth and
const { fetchStub, calls } = makeFetchStub([
{ status: 200, body: { schema: [{ type: "function", function: { name: "search_memory" } }] } },
]);
- const client = new CortexClient({ baseUrl: "https://api.signindoppl.com", token: "cxm_test_token", fetch: fetchStub });
+ const client = new CortexClient({ baseUrl: "https://api.signindoppl.com", token: "cxa_test_token", fetch: fetchStub });
const schema = await client.toolsSchema("anthropic");
assert.equal(calls.length, 1);
assert.equal(calls[0].method, "GET");
assert.equal(calls[0].url, "https://api.signindoppl.com/v1/tools/schema?format=anthropic");
- assert.equal(calls[0].headers["Authorization"], "Bearer cxm_test_token");
+ assert.equal(calls[0].headers["Authorization"], "Bearer cxa_test_token");
assert.equal(calls[0].headers["Accept"], "application/json");
+ assert.equal(calls[0].redirect, "manual");
assert.ok(!("Content-Type" in calls[0].headers), "GET requests must not send Content-Type");
assert.deepEqual(schema, [{ type: "function", function: { name: "search_memory" } }]);
});
@@ -101,13 +117,13 @@ test("callTool issues POST /v1/tools/call with {name, arguments} body and unwrap
const { fetchStub, calls } = makeFetchStub([
{ status: 200, body: { tool: "search_memory", result: { results: [{ id: "m_1" }] } } },
]);
- const client = new CortexClient({ baseUrl: "https://api.signindoppl.com", token: "cxm_test_token", fetch: fetchStub });
+ const client = new CortexClient({ baseUrl: "https://api.signindoppl.com", token: "cxa_test_token", fetch: fetchStub });
const result = await client.callTool("search_memory", { query: "release checklist" });
assert.equal(calls[0].method, "POST");
assert.equal(calls[0].url, "https://api.signindoppl.com/v1/tools/call");
- assert.equal(calls[0].headers["Authorization"], "Bearer cxm_test_token");
+ assert.equal(calls[0].headers["Authorization"], "Bearer cxa_test_token");
assert.equal(calls[0].headers["Content-Type"], "application/json");
assert.deepEqual(JSON.parse(calls[0].body ?? "{}"), { name: "search_memory", arguments: { query: "release checklist" } });
assert.deepEqual(result, { results: [{ id: "m_1" }] });
@@ -152,6 +168,36 @@ test("context forwards intent/tokenBudget/surface overrides", async () => {
});
});
+test("context forwards CMP session, model, projection, and scope options", async () => {
+ const { fetchStub, calls } = makeFetchStub([{ status: 200, body: {} }]);
+ const client = new CortexClient({ token: "t", fetch: fetchStub });
+
+ await client.context("plan the Project Atlas launch", {
+ intent: "plan",
+ format: "smp",
+ model: "claude",
+ sessionId: "session-atlas",
+ pin: true,
+ sector: "Project Atlas",
+ project: "Atlas",
+ asOf: "2026-07-30T00:00:00Z",
+ });
+
+ assert.deepEqual(JSON.parse(calls[0].body ?? "{}"), {
+ task: "plan the Project Atlas launch",
+ intent: "plan",
+ token_budget: 2000,
+ surface: "agent",
+ format: "smp",
+ model: "claude",
+ session_id: "session-atlas",
+ pin: true,
+ sector: "Project Atlas",
+ project: "Atlas",
+ as_of: "2026-07-30T00:00:00Z",
+ });
+});
+
test("search issues GET /v1/search with query + limit params", async () => {
const { fetchStub, calls } = makeFetchStub([{ status: 200, body: { results: [] } }]);
const client = new CortexClient({ token: "t", fetch: fetchStub });
@@ -214,6 +260,123 @@ test("baseUrl trailing slashes are stripped", async () => {
assert.ok(calls[0].url.startsWith("https://api.signindoppl.com/v1/search"));
});
+test("constructor rejects unsafe or malformed base URLs with CortexError", () => {
+ for (const baseUrl of [
+ "not-a-url",
+ "file:///tmp/cortex",
+ "https://user:secret@example.com",
+ "https://example.com?token=secret",
+ "https://example.com/#fragment",
+ ]) {
+ assert.throws(
+ () => new CortexClient({ baseUrl, fetch: makeFetchStub([]).fetchStub }),
+ (err: unknown) => err instanceof CortexError && err.status === 0,
+ baseUrl,
+ );
+ }
+});
+
+test("GET follows at most same-origin redirects and retains bearer auth", async () => {
+ const { fetchStub, calls } = makeFetchStub([
+ {
+ status: 307,
+ body: "",
+ headers: { Location: "/v1/search-relocated?query=atlas&limit=8" },
+ },
+ { status: 200, body: { results: [{ id: "m_atlas" }] } },
+ ]);
+ const client = new CortexClient({
+ baseUrl: "https://api.signindoppl.com",
+ token: "cxa_redirect_test",
+ fetch: fetchStub,
+ });
+
+ const result = await client.search<{ results: Array<{ id: string }> }>("atlas");
+
+ assert.deepEqual(result, { results: [{ id: "m_atlas" }] });
+ assert.equal(calls.length, 2);
+ assert.equal(
+ calls[1].url,
+ "https://api.signindoppl.com/v1/search-relocated?query=atlas&limit=8",
+ );
+ assert.equal(calls[1].headers["Authorization"], "Bearer cxa_redirect_test");
+ assert.equal(calls[1].redirect, "manual");
+});
+
+test("cross-origin redirect is rejected before bearer auth can be replayed", async () => {
+ const { fetchStub, calls } = makeFetchStub([
+ {
+ status: 302,
+ body: "",
+ headers: { Location: "https://attacker.invalid/collect" },
+ },
+ ]);
+ const client = new CortexClient({
+ baseUrl: "https://api.signindoppl.com",
+ token: "cxa_secret",
+ fetch: fetchStub,
+ });
+
+ await assert.rejects(
+ () => client.search("atlas"),
+ (err: unknown) => {
+ assert.ok(err instanceof CortexError);
+ assert.equal(err.status, 302);
+ assert.match(String(err.detail), /Cross-origin redirect blocked/);
+ return true;
+ },
+ );
+ assert.equal(calls.length, 1);
+});
+
+test("POST redirects are rejected rather than replaying a request body or token", async () => {
+ const { fetchStub, calls } = makeFetchStub([
+ { status: 307, body: "", headers: { Location: "/v1/context-relocated" } },
+ ]);
+ const client = new CortexClient({
+ baseUrl: "https://api.signindoppl.com",
+ token: "cxa_secret",
+ fetch: fetchStub,
+ });
+
+ await assert.rejects(
+ () => client.context("Project Atlas"),
+ (err: unknown) => {
+ assert.ok(err instanceof CortexError);
+ assert.equal(err.status, 307);
+ assert.match(String(err.detail), /Redirect blocked.*POST/);
+ return true;
+ },
+ );
+ assert.equal(calls.length, 1);
+});
+
+test("GET stops after three same-origin redirects", async () => {
+ const { fetchStub, calls } = makeFetchStub([
+ { status: 307, body: "", headers: { Location: "/redirect/1" } },
+ { status: 307, body: "", headers: { Location: "/redirect/2" } },
+ { status: 307, body: "", headers: { Location: "/redirect/3" } },
+ { status: 307, body: "", headers: { Location: "/redirect/4" } },
+ ]);
+ const client = new CortexClient({
+ baseUrl: "https://api.signindoppl.com",
+ token: "cxa_secret",
+ fetch: fetchStub,
+ });
+
+ await assert.rejects(
+ () => client.search("atlas"),
+ (err: unknown) => {
+ assert.ok(err instanceof CortexError);
+ assert.equal(err.status, 307);
+ assert.match(String(err.detail), /Too many Cortex API redirects/);
+ return true;
+ },
+ );
+ assert.equal(calls.length, 4);
+ assert.ok(calls.every((call) => call.headers["Authorization"] === "Bearer cxa_secret"));
+});
+
// -- error propagation ----------------------------------------------------------------
test("a non-2xx JSON {detail} response rejects with CortexError carrying status + detail", async () => {
diff --git a/site/app.js b/site/app.js
index 0ed93df5..ae3a8cb4 100644
--- a/site/app.js
+++ b/site/app.js
@@ -1,18 +1,18 @@
(function () {
const fallbackRelease = {
version: "0.2.0",
- build: "22",
+ build: "51",
channel: "local-beta",
artifacts: [
{
kind: "dmg",
- filename: "Cortex-0.2.0-22.dmg",
- url: "downloads/Cortex-0.2.0-22.dmg"
+ filename: "Cortex-0.2.0-51.dmg",
+ url: "https://github.com/doppl-tech/releases/releases/download/v0.2.0-51/Cortex-0.2.0-51.dmg"
},
{
kind: "zip",
- filename: "Cortex-0.2.0-22.app.zip",
- url: "downloads/Cortex-0.2.0-22.app.zip"
+ filename: "Cortex-0.2.0-51.app.zip",
+ url: "https://github.com/doppl-tech/releases/releases/download/v0.2.0-51/Cortex-0.2.0-51.app.zip"
}
]
};
diff --git a/site/downloads/distribution.json b/site/downloads/distribution.json
index 55954cf9..66f6817e 100644
--- a/site/downloads/distribution.json
+++ b/site/downloads/distribution.json
@@ -1,18 +1,18 @@
{
"artifacts": [
{
- "filename": "Cortex-0.2.0-22.dmg",
+ "filename": "Cortex-0.2.0-51.dmg",
"kind": "dmg",
- "sha256": "eb421cf0fd767b3aaf8a1d5ef05235186b67e056d30f299c777ae89d9cad34b9",
- "size_bytes": 87312603,
- "url": "downloads/Cortex-0.2.0-22.dmg"
+ "sha256": "b416c8af096d9bc2776adc306a9d60f843054bc35e503d993eda9e199436ec64",
+ "size_bytes": 74548179,
+ "url": "https://github.com/doppl-tech/releases/releases/download/v0.2.0-51/Cortex-0.2.0-51.dmg"
},
{
- "filename": "Cortex-0.2.0-22.app.zip",
+ "filename": "Cortex-0.2.0-51.app.zip",
"kind": "zip",
- "sha256": "eb8797c807fa2b50fc9a5972660ffd639348c59077a84c192a342f6266c4f2bd",
- "size_bytes": 68365377,
- "url": "downloads/Cortex-0.2.0-22.app.zip"
+ "sha256": "3a3ee9fc26fca2c3944c9bd4dff49fddb5f8dfc377b5efa14ce8966f5b34e273",
+ "size_bytes": 71687986,
+ "url": "https://github.com/doppl-tech/releases/releases/download/v0.2.0-51/Cortex-0.2.0-51.app.zip"
},
{
"filename": "cortex-memory-0.1.0.zip",
@@ -22,8 +22,8 @@
"url": "downloads/cortex-memory-0.1.0.zip"
}
],
- "build": "22",
+ "build": "51",
"channel": "local-beta",
- "prepared_at": "2026-07-09T19:23:49+00:00",
+ "prepared_at": "2026-07-24T03:54:50Z",
"version": "0.2.0"
}
diff --git a/site/faq.html b/site/faq.html
index d4aad380..9e9e4754 100644
--- a/site/faq.html
+++ b/site/faq.html
@@ -27,11 +27,6 @@
Support
FAQ & support
-
- Draft — pending legal review before public launch. These answers describe
- how the software actually behaves; the privacy answer below is a plain summary of the
- Privacy page, not a legal statement.
-
Short answers to the questions we hear most. Still stuck? Email
support@trydoppl.com. See also the
@@ -51,7 +46,7 @@
macOS won't let me open the app
Is my data private? Is it encrypted?
-
Local app (the default): your memory stays on your Mac and needs no account. It works with the network off, and nothing is sent to us.
+
Local memory (the default): the shipped beta requires a one-time account sign-in, but ingestion, retrieval, and your memory vault stay on your Mac unless you enable sync.
Cortex Cloud (optional): your data is stored in an isolated per-account store and is encrypted at rest with a key unique to your account. This is not end-to-end or "zero-knowledge" encryption: Cortex servers can read your memory in order to build embeddings, retrieve, and serve cited context to your AI tools. That is what makes the cloud features work.
Full detail — including deletion by crypto-erasure and what connectors read — is on the Privacy page.
@@ -61,7 +56,7 @@
Is my data private? Is it encrypted?
How do I connect the app to my account?
Open the app's Settings → "Cortex Cloud" section.
-
Enter the hosted URL (for example https://api.trydoppl.com).
+
Enter the hosted URL (for example https://api.signindoppl.com).
Sign in with your email and password, or use "Sign in with browser" for Google/GitHub. The "Create an account" link opens the signup page.
The app stores your session securely in the macOS Keychain and refreshes it automatically. Power users can still paste a raw endpoint and token in the manual fields.
@@ -97,7 +92,7 @@
A connector says it needs to reconnect — what do I do?
Does it work offline?
-
Yes. In local mode (the default) Cortex runs entirely on your Mac and works with the network off.
+
After the required sign-in, local ingestion and retrieval work with the network off. Signing in again, syncing, hosted retrieval, and connector authorization need a connection.
Cortex Cloud features (syncing, hosted retrieval) need a connection, since they run against the hosted backend.
@@ -105,7 +100,7 @@
Does it work offline?
How do I update the app?
-
Cortex checks for updates and will prompt you when a new version is available; follow the prompt to install it.
+
Cortex checks for updates and links you to the current download. It does not replace the running app automatically.
You can also download the latest version from the download section and replace the app in your Applications folder.
- This build is for early testers who want local AI memory today. It is ad-hoc signed for local beta testing and not yet notarized for broad public distribution.
+ This build is for early testers who want local AI memory today. The current DMG is Developer ID signed, notarized by Apple, and distributed through GitHub Releases.
+ Cortex remains a local-first beta; cloud backup and production support are not yet part of this release.
For support or deletion questions, email support@trydoppl.com.
- Cortex has two ways to run, and your data is handled differently in each. The
- local-first macOS app keeps your memory on your own Mac and needs no
- account. Cortex Cloud is an optional hosted tier you sign in to; it
- exists to sync and serve your memory to AI tools, and is described honestly below.
+ Cortex has two data paths, and your data is handled differently in each. The
+ shipped local-first macOS app requires account sign-in, while keeping
+ its memory vault and retrieval engine on your Mac. Cortex Cloud is the
+ optional hosted sync and retrieval tier described below.
Local app — the default
Memory lives on your Mac at ~/Library/Application Support/Cortex/Cortex.vault/ (or a folder you choose).
-
The local app requires no account and no cloud service. It works with the network off.
+
The shipped beta requires a one-time account sign-in. After sign-in, local ingestion and retrieval work with the network off; sign-in and optional sync require the network.
Cortex reads clipboard content only when you trigger a capture, and a local notes folder only after you choose it.
It does not record your screen, listen through the microphone, or capture ambient activity.
Stored: memories you approve, plus extracted tasks, entities, topics, graph edges, settings, audit events, and local backups — all as readable files plus a rebuildable index.
A "subprocessor" is a third party that processes Cortex Cloud user data on our behalf.
- The local app uses no subprocessors — it runs entirely
- on your Mac. The table below applies only to the optional hosted
- Cortex Cloud tier. See the Privacy page for
- how data is handled overall.
+ The installed app keeps its memory vault, ingestion, embeddings, and retrieval engine on
+ your Mac by default. Account creation and sign-in, managed connector authorization,
+ optional sync, update checks, and support links can still contact Cortex-hosted or
+ third-party services. The table below covers processors used to operate the optional
+ hosted Cortex Cloud tier; a service you connect directly (for example
+ Google, GitHub, or Notion) processes data under its own terms rather than as a Cortex
+ subprocessor. See the Privacy page for how data is handled
+ overall.
Local app (the default): a macOS application that keeps your memory on your own Mac and needs no account. It works with the network off. Nothing in these Terms about accounts, servers, or cloud processing applies while you use Cortex only locally.
+
Local-first app (the default): a macOS application that requires account sign-in but keeps its memory vault and retrieval engine on your own Mac. After sign-in, local ingestion and retrieval work with the network off. Cloud-processing terms apply only to account metadata and content you choose to sync.
Cortex Cloud (optional): a hosted service you sign in to. It syncs your memory and serves it back to your AI tools with cited context. Using it means your data is processed on our servers as described below and in the Privacy page.
@@ -94,7 +94,7 @@
5. Your content and the license you give us
6. How your data is processed (AI processing disclosure)
-
When you use Cortex Cloud, your content is processed on our servers to build embeddings, run retrieval, and assemble cited context. This means Cortex servers can read your memory in order to serve you. Your data is encrypted at rest with a per-user key; this is not end-to-end or "zero-knowledge" encryption. The Privacy page states this plainly.
+
When you use Cortex Cloud, your content is processed on our servers to build embeddings, run retrieval, and assemble cited context. This means Cortex servers can read your memory in order to serve you. Connector credentials are encrypted at rest with a per-user key; broader encryption of hosted memory content is still rolling out. This is not end-to-end or "zero-knowledge" encryption. The Privacy page states this plainly.
By default the hosted deployment generates embeddings with an on-device model and uses no third-party AI processor. If the operator enables a cloud AI model, that provider is disclosed on the Subprocessors page.
Context is shared with an AI tool only when you connect it, and only within the permissions you grant.
@@ -112,18 +112,17 @@
7. Connectors and connected sources
8. Availability
Cortex Cloud is provided on an "as is" and "as available" basis. The free tier carries no uptime commitment or service-level agreement. We may change, suspend, or discontinue features, and we perform maintenance that can cause downtime.
-
The local app keeps working regardless of the hosted service's availability.
+
After setup and a successful sign-in, the installed app can continue local ingestion and retrieval over data already stored on your Mac during a hosted-service outage. Account creation, first sign-in, token renewal, managed connector authorization, optional sync, update checks, and other network-backed features may be unavailable until the relevant service recovers.
9. Paid plans (future)
-
Cortex may launch a paid tier. When it does, the following applies to paid plans:
-
-
Billing: payments are handled by a third-party merchant of record ([billing provider — confirm]), who processes your payment and, where a merchant of record, collects and remits applicable taxes. We receive payment metadata (not your full card details). Subscriptions renew automatically until cancelled; you can cancel at any time and keep access through the paid period you already bought.
-
Refunds:[REFUND POLICY PLACEHOLDER — state the window and conditions, e.g. "full refund within 14 days of a charge if the service was unused," and confirm the billing provider's own refund handling.]
-
EU/UK right of withdrawal: if you are a consumer in the EU/UK, you have a statutory 14-day right to withdraw from a purchase of digital services. Where you ask us to begin the service immediately, you acknowledge you may lose that right once the service is fully performed, to the extent the law allows. Nothing here limits your mandatory statutory rights.
-
+
+ Cortex does not currently offer a public paid plan. Before charging users, we will
+ publish the billing provider, renewal, cancellation, refund, and applicable
+ withdrawal terms and ask affected users to accept the updated Terms.
+