AI Access Backend Implementation
Overview
Theai 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.
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 outboundProviderFactory. - Use Cases:
AccessGate(authorization),AuthzRunner(provider execution),PromptService(DB-managed prompt templates), plusOrgConfigService/AdminServicefor configuration. - Adapters:
- Outbound external — provider clients:
anthropic,openai,google,groq,ollama, behind aprovider_factory. - Outbound crypto —
aes_gcmfor 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.
- Outbound external — provider clients:
The Access Gate Process
Every AI call is three steps, and the contract is strict:- Authorize —
AccessGate.Authorize(ctx, orgID, userID, feature, requestID)→AuthzResultorAuthzError. - Run —
AuthzRunner.RunChat(ctx, authz, chatRequest)builds the provider from the credentials on theAuthzResultand sends the request. - Record — the caller MUST follow up with exactly one of
AccessGate.RecordUsage(on success) orAccessGate.RecordError(on failure), keyed on the samerequestID.
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:
- Global kill-switch — the
admin_feature_flagsrow withkey = 'ai'. If disabled, every call is denied (ai_globally_disabled, 403), including BYOK test calls. Independent of per-org mode. - Load config + lazy-provision — reads
ai_configsfor 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). - Disabled mode —
mode = 'disabled'is always denied (ai_disabled, 403). - Per-mode authorization — branches on Mode:
- trial → checks the one-shot token/call caps →
trial_exhausted(402) when hit. Provider/model come fromai_config_defaults(centrally controlled). - platform → validates the Subscription (
subscription_inactive, 402) and the Plan caps (platform_cap_exceeded, 402); a deprecated selected model returnsmodel_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.
- trial → checks the one-shot token/call caps →
- Record decision — a row is written to
ai_access_eventsfor every decision (allow or deny), keyed onrequestID. Successful calls later enrich the row withprovider,model,latency_ms,http_status,error_code, andprovider_request_idvia the idempotentRecordUsage/RecordErrorupdate.
HTTP status discipline
The status code reflects who fixes it; thecode 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.Related
- Root glossary:
CONTEXT.md→ AI access - Digital Worker Module — the heaviest gate consumer
- Fieldforce Module — parse-task and risk interventions go through the gate
- ADR-0028 (two-tier LLM), ADR-0025 (DB-managed prompts)