let d = import "defaults.ncl" in

d.make_adr {
  id     = "adr-005",
  title  = "Unified Key-to-Session Auth Model Across CLI, UI, and MCP",
  status = 'Accepted,
  date   = "2026-03-13",

  context = "ontoref-daemon exposes project knowledge and mutations over HTTP, a browser UI, and an MCP server. Projects can define argon2id-hashed keys in project.ncl (with role admin|viewer and an audit label). Prior to this ADR, the UI login flow set a session cookie but the REST API accepted raw passwords as Bearer tokens on every request — each call paying ~100ms for argon2 verification. The CLI had no Bearer support at all; project-add and project-remove called the daemon without credentials. The daemon manage page had no admin identity concept. There was no way to enumerate or revoke active sessions.",

  decision = "All surfaces exchange a raw key once via POST /sessions for a UUID v4 bearer token (30-day lifetime, in-memory SessionStore). The session token is used for all subsequent calls — O(1) DashMap lookup. Sessions carry a stable public id (distinct from the bearer token) for safe list/revoke operations without leaking credentials. Project keys have a label field for audit trail. Daemon-level admin uses a separate argon2id hash (ONTOREF_ADMIN_TOKEN_FILE preferred over ONTOREF_ADMIN_TOKEN) and creates sessions under virtual slug '_daemon'. The CLI injects ONTOREF_TOKEN as Authorization: Bearer automatically via bearer-args in store.nu. Key rotation (PUT /projects/{slug}/keys) revokes all active sessions for the rotated project. GET /sessions and DELETE /sessions/{id} implement two-tier visibility: project admin sees own project sessions; daemon admin sees all.",

  rationale = [
    {
      claim  = "Token exchange eliminates per-request argon2 cost",
      detail = "argon2id verification takes ~100ms by design (brute-force resistance). Verifying on every CLI invocation, MCP tool call, or UI AJAX request is prohibitive. A UUID v4 session token turns auth into a DashMap::get — O(1), sub-microsecond. The argon2 cost is paid exactly once per login.",
    },
    {
      claim  = "session.id != bearer token prevents session enumeration attacks",
      detail = "The list endpoints (GET /sessions) return SessionView with a stable public id. If the bearer token were exposed in list responses, a project admin could steal another admin's token and impersonate them. The id field is a second UUID v4 — safe to expose, useless as a credential.",
    },
    {
      claim  = "is_uuid_v4 fast-path preserves backward compatibility",
      detail = "check_primary_auth tries the session token path first (UUID v4 format check is a length + byte comparison, ~10ns). If the bearer is not UUID-shaped, it falls through to argon2 password verification. Existing integrations using raw passwords continue to work without change.",
    },
    {
      claim  = "Virtual '_daemon' slug isolates admin sessions from project sessions",
      detail = "Daemon admin sessions are not scoped to any real project. The '_daemon' slug cannot collide with real project slugs (kebab-case, no leading underscore). AdminGuard checks for Role::Admin across any registered project or '_daemon', giving the manage page a clean auth boundary without coupling it to any specific project.",
    },
    {
      claim  = "ONTOREF_ADMIN_TOKEN_FILE preferred over inline env var",
      detail = "An inline hash in ONTOREF_ADMIN_TOKEN appears in `ps aux` output and shell history. Reading from a file (ONTOREF_ADMIN_TOKEN_FILE) avoids both surfaces. The file contains only the argon2id hash string; the actual password never touches the daemon process env.",
    },
    {
      claim  = "Key rotation invalidates sessions atomically",
      detail = "When keys are rotated, all in-flight sessions for that project authenticated against the old key set are immediately invalid. revoke_all_for_slug scans the DashMap and removes matching entries including the id_index. Actor sessions (ontoref-daemon actors registry) are also invalidated. The UI will receive 401 on next request and redirect to login.",
    },
  ],

  consequences = {
    positive = [
      "REST API, MCP, and UI all use the same session token — single auth model, no surface-specific logic",
      "CLI project-add, project-remove, and notify-daemon-* calls carry credentials automatically when ONTOREF_TOKEN is set",
      "Session list gives admins visibility into who is connected to their project",
      "Revocation is O(1) via id_index secondary index; bulk revocation on key rotation is O(active sessions for slug)",
      "Daemon admin and project admin are cleanly separated — '_daemon' sessions cannot access project-scoped endpoints and vice versa",
    ],
    negative = [
      "Sessions are in-memory only — daemon restart requires all clients to re-authenticate",
      "30-day token lifetime means a leaked token is valid for up to 30 days unless explicitly revoked",
      "ONTOREF_TOKEN in shell env is visible to child processes — use a secrets manager or short-lived token refresh for automated pipelines",
    ],
  },

  alternatives_considered = [
    {
      option       = "Verify argon2 on every Bearer request (no session concept)",
      why_rejected = "~100ms per request is unacceptable for CLI invocations (each command is a new HTTP call) and MCP tool sequences (multiple calls per agent turn). Session token lookup is sub-microsecond.",
    },
    {
      option       = "Axum middleware for auth instead of per-handler check_primary_auth",
      why_rejected = "Middleware applies uniformly to all routes. ontoref-daemon coexists auth-enabled and open projects on the same router — some endpoints are always public (health, POST /sessions itself), some require project-scoped auth, some require daemon admin. Per-handler checks encode these rules explicitly; middleware would require complex route exemption logic.",
    },
    {
      option       = "Expose bearer token in session list responses",
      why_rejected = "GET /sessions is accessible to project admins. Exposing the bearer would allow any project admin to impersonate any other session holder. The public session.id is a safe substitute for revocation targeting.",
    },
    {
      option       = "Separate token store per project",
      why_rejected = "A single DashMap keyed by token with a slug field in SessionEntry is sufficient. A secondary id_index DashMap gives O(1) revoke-by-id. Per-project sharding would add complexity without benefit given the expected session count (tens, not millions).",
    },
    {
      option       = "JWT instead of opaque UUID v4 tokens",
      why_rejected = "JWTs are self-contained and cannot be revoked without a denylist. Opaque tokens enable instant revocation (key rotation, logout, admin force-revoke) with O(1) lookup. The daemon is local-only — there is no distributed verification scenario that would justify JWT complexity.",
    },
  ],

  constraints = [
    {
      id       = "session-token-never-in-list-response",
      claim    = "GET /sessions responses must never include the bearer token, only the public session id",
      scope    = "crates/ontoref-daemon/src/session.rs, crates/ontoref-daemon/src/api.rs",
      severity = 'Hard,
      check    = { tag = 'Grep, pattern = "pub token", paths = ["crates/ontoref-daemon/src/session.rs"], must_be_empty = true },
      rationale = "Exposing bearer tokens in list responses would allow admins to impersonate other sessions. The session.id field is a second UUID v4, safe to expose.",
    },
    {
      id       = "post-sessions-no-auth-required",
      claim    = "POST /sessions must not require authentication — it is the credential exchange endpoint",
      scope    = "crates/ontoref-daemon/src/api.rs",
      severity = 'Hard,
      check    = { tag = 'Grep, pattern = "require_session|check_primary_auth", paths = ["crates/ontoref-daemon/src/api.rs"], must_be_empty = false },
      rationale = "Requiring auth to obtain auth is a bootstrap deadlock. Rate-limiting on failure is the correct mitigation, not pre-authentication.",
    },
    {
      id       = "key-rotation-must-invalidate-sessions",
      claim    = "PUT /projects/{slug}/keys must call revoke_all_for_slug before persisting new keys",
      scope    = "crates/ontoref-daemon/src/api.rs",
      severity = 'Hard,
      check    = { tag = 'Grep, pattern = "revoke_all_for_slug", paths = ["crates/ontoref-daemon/src/api.rs"], must_be_empty = false },
      rationale = "Sessions authenticated against the old key set become invalid after rotation. Failing to revoke them would leave stale sessions with elevated access.",
    },
    {
      id       = "bearer-args-injected-by-cli",
      claim    = "All CLI HTTP calls to the daemon must use bearer-args from store.nu — no hardcoded curl without auth args",
      scope    = "reflection/modules/store.nu, reflection/bin/ontoref.nu",
      severity = 'Soft,
      check    = { tag = 'Grep, pattern = "bearer-args|http-get|http-post-json|http-delete", paths = ["reflection/modules/store.nu"], must_be_empty = false },
      rationale = "ONTOREF_TOKEN is the single credential source for CLI. Direct curl without bearer-args bypasses the auth model silently.",
    },
  ],

  related_adrs = ["adr-002-daemon-for-caching-and-notification-barrier"],

  ontology_check = {
    decision_string    = "unified key-to-session token exchange; opaque UUID v4 bearer; session.id != token; revocation on key rotation",
    invariants_at_risk = ["no-enforcement"],
    verdict            = 'RequiresJustification,
  },

  invariant_justification = {
    invariant  = "no-enforcement",
    claim      = "Auth is opt-in per project. A project with no keys configured runs in open mode — all endpoints accessible without credentials. The protocol itself never mandates auth.",
    mitigation = "The no-enforcement axiom applies at the protocol level. Auth is a project-level operational decision, not a protocol constraint. Open deployments (no keys) pass through all auth checks unconditionally.",
  },
}
