This page mirrors
docs/SECURITY.md, which is the authoritative
document on the V1 security model (sprint 6). The tone stays
deliberately the same: honest about what is done and about what
isn’t yet.Auth model: per-project API keys
A key (hk_<hex>) is bound to one org_id + one project_id.
Only the sha256 hash is stored (table api_keys, migration 0006);
the clear key is returned only at creation. The prefix (first 8
characters) powers masked displays.
HAKI_AUTH_REQUIRED=true(default): every/v1/*endpoint (except key management) and/gateway/v1/*requiresAuthorization: Bearer hk_.... Missing, invalid or revoked key → 401unauthorized.- Scope binding: if the body or the query carries a
project_iddifferent from the key’s → 403forbidden_scope, a generic message, no hint about the existence of other projects. Verified on capture (each event’sproject_id), context, feedback, forget, resolve (body) and timeline, inspect, conflicts (query). /gateway/v1/*: same middleware, same key. The chat-completions body carries noproject_id: the memory scope is the key’s, no exception. The Haki key is never forwarded to the LLM provider — the upstream call only ever uses the server-sideHAKI_LLM_*credentials.HAKI_AUTH_REQUIRED=false= open dev mode, documented, never in production: an explicit warning is logged at startup (haki.main).
Key management
Access rules (V1, deliberately simple):
HAKI_ADMIN_KEYset → admin mode: all key management requiresAuthorization: Bearer <HAKI_ADMIN_KEY>.- Unset → documented bootstrap: the first creation is free
(empty table). After that, a valid key only manages the keys of its
own project — a key from another project returns the same 404
key_not_foundas an unknown id, no leak.
Policy Engine V1 (app/policy/)
A deterministic module (no LLM), called before the action by
capture, context, forget and by the auth middleware. Three rules in V1
(no custom user rules yet — later sprint):
1
Scope present
subject_id is non-empty on every captured event (missing_scope,
consistent with the Ledger).2
Key ↔ project
The scope binding above (403
forbidden_scope).3
purpose recommended on context
missing_purpose warning in the packet (and the persisted trace),
not an error in V1.haki.policy, policy_decision {...}); every forget is audited. Typed
errors: unauthorized, forbidden_scope, missing_scope, never a
cross-project reveal.
Row-Level Security (migration 0006)
RLS enabled +FORCE ROW LEVEL SECURITY on events, facts,
context_traces, conflict_sets, policy haki_project_isolation:
- The
get_sessiondependency runsSELECT set_config('haki.project_id', :pid, true)(SET LOCAL, transaction scope) from the key resolved by auth. PRD guarantee: a query that forgets theproject_idfilter in code only ever sees the calling key’s project rows — proven bytests/test_rls.py(aSELECTwith no.where, a cross-projectINSERTrejected by theWITH CHECK). - Open dev mode: no
SET,NULLGUC → permissive policy. This is also the mode of the internal worker and the MCP server (both project and subject are fixed by server config — neitherhaki_*tool accepts asubject_idparameter). NULLIF(..., '')is essential: after aSET LOCALis rolled back at the end of a transaction, Postgres leaves the custom GUC at''(notNULL), and pooled connections reuse it — without this, any connection that had served an authenticated request would hide every row for the next caller (a bug found live in a demo, regression testtest_rls_empty_string_setting_is_permissive).- Two roles: migrations run with the owner role
haki(DDL); the runtime useshaki_app(created by the migration, passwordhaki— a local dev credential to replace on deployment), which is neither superuser nor owner. A superuser bypasses RLS even withFORCE: without this dedicated role the guarantee would be a fiction. Config:HAKI_DATABASE_URL(runtime,haki_app) andHAKI_MIGRATION_DATABASE_URL(alembic,haki). /v1/consolidateremains a dev/ops, cross-project endpoint: a session with no RLS context (get_session_ops), documented.
Feedback and conflict resolution
POST /v1/feedback: a rating="incorrect" on a fact_id transitions
the fact to disputed — the Context Assembler no longer serves it as
active. POST /v1/conflicts/{id}/resolve settles a contradiction with
full history (the loser becomes superseded, never deleted). See
Feedback and
Conflicts.
Secrets
The LLM key lives in.env (git-ignored, template provided in
.env.example), never in code, the terminal, or the frontend.
Multilingual proof
scripts/check_multilingual.py (server run with
HAKI_LLM_PROVIDER=openai, local embedder
paraphrase-multilingual-MiniLM-L12-v2): captures FR + EN + ES for one
subject, consolidation, cross-language queries (EN→FR, FR→ES, ES→EN) —
3/3 PASS on the last documented run. Extracted predicates/values stay
in technical English regardless of the input language: intentional and
documented — the input language does not constrain the fact schema, only
semantic fidelity matters.

