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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,29 @@ Two things are versioned separately from this file and worth knowing about:

## [Unreleased]

### Changed

- **Embeddings are paid for with the collection's vault key, and nothing else.**
`OPENROUTER_API_KEY` is gone: it was a deployment-wide fallback for one
provider, left over from when `openrouter.ai` was hardcoded, and the only
reason the catalog carried a `deployment_key` flag, the resolver two fallback
states and the form a "Deployment key" row. A new personal or organization
collection names its provider from `embedding_providers.json` and the vault
key that pays, or is refused on that field; a collection whose key is missing,
unusable or never chosen refuses to index or search with a message naming the
collection and the reason, and the ingestion flow log says so. A key can be
replaced but no longer cleared (`clear_embedding_secret` is removed), because
there is nothing to fall back to. App-scoped collections have no vault and
stay keyless. (#1596)

### Added

- **`docs/data-protection.md`** - where personal data lives, what leaves the
deployment and under which setting, the controls with their proof or their
open issue, what deletion reaches, and a reproducible verification checklist
for one deployment. Linked from the security-review table, `SECURITY.md` and
the topic map. (#1596)

## [0.0.409] - 2026-09-11

### Security
Expand Down
1 change: 1 addition & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -391,6 +391,7 @@ say so and move on. Run it yourself any time with
| Which model to pick, open weights vs closed | `docs/choosing-models.md` |
| Adoption, roles, cost, the security review | `docs/rollout.md` |
| The vault, secret kinds, what never leaks | `docs/secrets.md` |
| Where personal data lives, what leaves, deletion, the controls and the open gaps | `docs/data-protection.md` |
| Skills — format, library, skills vs knowledge | `docs/skills.md` |
| Context files — standing knowledge bound to agents | `docs/context.md` |
| Named environments, promotion, per-environment tracing | `docs/environments.md` |
Expand Down
1 change: 1 addition & 0 deletions SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ We aim to acknowledge within 48h and ship a fix within 7 days for high-severity
- **Secrets** — read from environment via `pydantic-settings`. Never committed. See `backend/.env.example` and [Configuration](docs/configuration.md).
- **Audit log** — app-admin actions (user updates, deletes, impersonations) recorded in the `app_admin_audit_logs` table with actor + IP + payload snapshot. Organization-level actions that change access or spend money carry their own trail, gated by `audit:read` — see [Governance](docs/governance.md).
- **RAG documents** — file uploads scoped per-org. No public read endpoint; all retrieval happens server-side during chat.
- **Personal data** — where it lives, what leaves the deployment and under which setting, what deletion reaches and what it does not, with the open gaps named: [Data protection](docs/data-protection.md).

### Hardening checklist for production

Expand Down
7 changes: 3 additions & 4 deletions backend/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -83,10 +83,9 @@ REDIS_PORT=6379
REDIS_DB=0

# === Embeddings ===
# The one credential every collection embeds on (via OpenRouter). Chat models
# are NOT configured here — each organization stores its own provider keys in
# the vault and picks models per agent.
OPENROUTER_API_KEY=
# No credential here. Every collection names the organization vault key that
# pays for its embeddings and the provider it embeds through, the same way chat
# models are keyed per organization.
# Deployment-level on purpose: pgvector columns are created at this model's
# width, so changing it mid-life invalidates every existing collection.
EMBEDDING_MODEL=text-embedding-3-large
Expand Down
7 changes: 3 additions & 4 deletions backend/app/api/routes/v1/rag.py
Original file line number Diff line number Diff line change
Expand Up @@ -95,18 +95,17 @@ async def list_embedding_models() -> Any:
Deployment description, like `/supported-formats`: the list feeds the
create-collection form and the one that moves an existing collection to
another provider, and hardcoding it in the client is how the form and the
build drift apart. The defaults are named so the form can preselect what an
untouched deployment would use.
build drift apart. The default model is named so the form can preselect
what an untouched deployment would build a collection with; the provider
and the vault key that pays are the collection's own choice, every time.
"""
return {
"default": settings.EMBEDDING_MODEL,
"default_provider": embedding_providers.deployment_provider().provider,
"providers": [
{
"provider": entry.provider,
"name": entry.name,
"models": [{"model": model.model, "dim": model.dim} for model in entry.models],
"deployment_key": entry.deployment_key,
}
for entry in embedding_providers.providers()
],
Expand Down
1 change: 0 additions & 1 deletion backend/app/core/catalog/embedding_providers.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@
"provider": "openrouter",
"name": "OpenRouter",
"base_url": "https://openrouter.ai/api/v1",
"deployment_key": true,
"models": [
{ "model": "text-embedding-3-small", "dim": 1536 },
{ "model": "text-embedding-3-large", "dim": 3072 },
Expand Down
8 changes: 3 additions & 5 deletions backend/app/core/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -318,11 +318,9 @@ def REDIS_URL(self) -> str:
# otherwise start all of them - see app/worker/prefect_app.py.
PREFECT_RUNNER_LIMIT: int = 5

# The embeddings credential. Every collection in the deployment is embedded
# on this key (via OpenRouter); model *profiles* in the vault cover chat
# models only. Moving this to per-organization credentials is a feature,
# not a rename - the vector column width is bound to EMBEDDING_MODEL below.
OPENROUTER_API_KEY: str = ""
# There is no deployment-wide embedding credential: every collection names
# the organization vault key that pays for its embeddings, and the provider
# it embeds through, from `app/core/catalog/embedding_providers.json`.
# Deployment-level on purpose: pgvector columns are created at this model's
# width, so changing it mid-life invalidates every existing collection.
# ingestion_config guards both directions of that mistake.
Expand Down
11 changes: 4 additions & 7 deletions backend/app/repositories/knowledge_base.py
Original file line number Diff line number Diff line change
Expand Up @@ -168,13 +168,12 @@ async def update(
ingestion_config: dict[str, object] | None = None,
embedding_provider: str | None = None,
embedding_secret_id: UUID | None = None,
clear_embedding_secret: bool = False,
) -> KnowledgeBase:
"""Apply what an update named, leaving what it did not alone.

`clear_embedding_secret` is separate from a null `embedding_secret_id`
because both have to be sayable: on a partial update null means "leave the
key alone", so going back to the deployment's key needs a word of its own.
A null `embedding_secret_id` means "leave the key alone": a collection is
never left without a key, because there is no deployment-wide one to fall
back to.
"""
if name is not None:
db_kb.name = name
Expand All @@ -184,9 +183,7 @@ async def update(
db_kb.ingestion_config = ingestion_config
if embedding_provider is not None:
db_kb.embedding_provider = embedding_provider
if clear_embedding_secret:
db_kb.embedding_secret_id = None
elif embedding_secret_id is not None:
if embedding_secret_id is not None:
db_kb.embedding_secret_id = embedding_secret_id
await db.flush()
await db.refresh(db_kb)
Expand Down
24 changes: 9 additions & 15 deletions backend/app/schemas/knowledge_base.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,17 +37,18 @@ class KnowledgeBaseCreate(BaseSchema):
description=(
"Whose endpoint serves that model, from "
"`GET /rag/embedding-models`. Unlike the model this one can be "
"changed later. Omit for the provider the deployment's own key "
"belongs to."
"changed later. Required for a new collection; a knowledge base "
"joining a collection name already in use adopts that collection's."
),
)
embedding_secret_id: UUID | None = Field(
default=None,
description=(
"The organization vault key that pays for this collection's "
"embeddings. Must be a key for the chosen provider. Omit to use "
"the deployment's key, which only the deployment's own provider "
"can be paid with."
"embeddings. Must be a key for the chosen provider. There is no "
"deployment-wide key: a new personal or organization collection "
"must name one, and a knowledge base joining an existing collection "
"adopts its key."
),
)
ingestion_config: IngestionConfig | None = Field(
Expand Down Expand Up @@ -94,16 +95,9 @@ class KnowledgeBaseUpdate(BaseSchema):
default=None,
description=(
"The organization vault key that pays from now on. Must be a key "
"for the provider the collection ends up on. Send "
"`clear_embedding_secret` to go back to the deployment's key."
),
)
clear_embedding_secret: bool = Field(
default=False,
description=(
"Stop using a vault key and fall back to the deployment's. Needed "
"because a null `embedding_secret_id` means 'leave it alone' on a "
"partial update, and both must be sayable."
"for the provider the collection ends up on. Null leaves the key "
"alone; a collection cannot be left without one, because there is "
"no deployment-wide key to fall back to."
),
)

Expand Down
5 changes: 0 additions & 5 deletions backend/app/schemas/rag.py
Original file line number Diff line number Diff line change
Expand Up @@ -224,10 +224,6 @@ class EmbeddingProviderEntry(BaseSchema):
provider: str
name: str
models: list[EmbeddingModelEntry]
# Whether this deployment's own key pays here. A collection on any other
# provider needs a key of its own, and the form says so rather than letting
# somebody create a collection that cannot index its first document.
deployment_key: bool


class EmbeddingModelsResponse(BaseSchema):
Expand All @@ -241,5 +237,4 @@ class EmbeddingModelsResponse(BaseSchema):
"""

default: str
default_provider: str
providers: list[EmbeddingProviderEntry]
Loading