AI Access Backend Implementation

Overview

The ai module owns how every AI call is sourced, authorized, billed, and recorded. It follows the same hexagonal clean architecture (Ports & Adapters + DDD) as leave and fieldforce. Its core is the Access Gate: the single chokepoint (单一入口) that every AI call — from any module — must pass through before reaching a provider. Feature modules (Fieldforce parse-task and risk interventions; Digital Worker mention-response, task extraction, digests, learning) never call a provider directly. They always go through the gate.
Domain terms used here (Mode, Plan, Subscription, BYOK, Access Event, HTTP status discipline) are defined once in the root CONTEXT.md → AI access glossary. This page documents the process; CONTEXT.md is the source of truth for the vocabulary.

Layered Structure

  • Domain Entities: AccessConfig (per-org AI state), AuthzResult (the gate’s output — credentials + metadata), AuthzError, AccessMode (trial/platform/byok/disabled), Plan, AIProvider, PromptTemplate.
  • Application Ports: repository interfaces (AIConfigRepository, AIPlansRepository, AIModelsRepository, AIAccessEventRepository, UsageRollupRepository, FeatureFlagRepository, KeyCrypto, Clock) and the outbound ProviderFactory.
  • Use Cases: AccessGate (authorization), AuthzRunner (provider execution), PromptService (DB-managed prompt templates), plus OrgConfigService / AdminService for configuration.
  • Adapters:
    • Outbound external — provider clients: anthropic, openai, google, groq, ollama, behind a provider_factory.
    • Outbound cryptoaes_gcm for BYOK key encryption at rest (per-row nonce).
    • Outbound persistence — PostgreSQL repos for configs, access events, usage, prompts.
    • Inbound HTTP — org-facing config endpoints, platform-admin endpoints, prompt management.

The Access Gate Process

Every AI call is three steps, and the contract is strict:
  1. AuthorizeAccessGate.Authorize(ctx, orgID, userID, feature, requestID)AuthzResult or AuthzError.
  2. RunAuthzRunner.RunChat(ctx, authz, chatRequest) builds the provider from the credentials on the AuthzResult and sends the request.
  3. Record — the caller MUST follow up with exactly one of AccessGate.RecordUsage (on success) or AccessGate.RecordError (on failure), keyed on the same requestID.
The gate does not call providers itself. It returns the credentials and metadata; the caller runs the provider call, then records the outcome. Skipping the RecordUsage/RecordError follow-up leaves usage counters and the access event row incomplete.

What Authorize checks, in order

AccessGate.Authorize runs a fixed pipeline. The first failing step returns an AuthzError that the caller returns to the HTTP client verbatim:
  1. Global kill-switch — the admin_feature_flags row with key = 'ai'. If disabled, every call is denied (ai_globally_disabled, 403), including BYOK test calls. Independent of per-org mode.
  2. Load config + lazy-provision — reads ai_configs for the org. An org with no row is trial-eligible-but-uninitialized: the gate provisions a trial row via upsert on first call (org creation never writes AI state). If defaults or the trial plan are absent, the call is denied (ai_disabled).
  3. Disabled modemode = 'disabled' is always denied (ai_disabled, 403).
  4. Per-mode authorization — branches on Mode:
    • trial → checks the one-shot token/call caps → trial_exhausted (402) when hit. Provider/model come from ai_config_defaults (centrally controlled).
    • platform → validates the Subscription (subscription_inactive, 402) and the Plan caps (platform_cap_exceeded, 402); a deprecated selected model returns model_deprecated (502 — the org didn’t break it, we did).
    • byok → resolves and decrypts the org’s provider key (no_byok_key / invalid_byok_key, 422). No platform-side usage caps.
  5. Record decision — a row is written to ai_access_events for every decision (allow or deny), keyed on requestID. Successful calls later enrich the row with provider, model, latency_ms, http_status, error_code, and provider_request_id via the idempotent RecordUsage/RecordError update.

HTTP status discipline

The status code reflects who fixes it; the code is the routing key for client logic (ADR alignment in CONTEXT.md): Frontends match on code, never on the bare status number.

Access Modes

Transitions are enforced by the config endpoints — see CONTEXT.md → Mode for the allowed-transition table.

Consuming the Gate from Other Modules

The three steps always travel together, so the composition root (internal/app.Container) hands them to feature modules as one AI capability (see CONTEXT.md → AI capability): Gate (Authorize + Record), Runner (RunChat), and Prompt (GetActive), exposed present-or-absent (AI() (cap, ok)). When the ai module is disabled the capability is absent, and each consumer decides for itself — Fieldforce risk-generation degrades to heuristic-only, parse-task skips wiring its route. Consumers depend on narrow local ports (e.g. fieldforce/.../port.BriefingGate, digitalworker/.../port.Gate) that the gate satisfies structurally, so a feature can be tested against a fake gate without constructing the full authorization graph.

Two-Tier LLM (Digital Worker)

The Digital Worker pipeline adds an unbilled local triage step (Ollama) before the gate — a cheap pre-filter that decides whether a remote, gated call is worth making (ADR-0028). The gate remains the single authority for the billed remote call.