An agent with write access to your ontology
Our AI FDE can edit a live data model. The gate around it: a short-lived key scoped to you, three server-enforced tiers, and a signed exposure register.
The AI FDE drawer in Certant can rename a column in a live ontology, activate a monitor that pages someone at 3am, and flip Strata on for a whole knowledgebase. That's a lot of authority to hand a language model, and the honest reaction when we designed it was "absolutely not, unless".
Most of the engineering went into the "unless".

It acts as you, and the key dies with the session
Opening the drawer triggers a credential exchange, not a service account. POST /api/fde/sessions exchanges your user JWT for an ephemeral scoped key via the user-key generator: hash-and-discard, plaintext returned once. The session row lands in a dedicated fde schema in PostgreSQL, and a down PostgreSQL returns 503 instead of falling back to a local store.
The scopes on that key are fixed at mint time:
scopes = ["fde", "strata_read", "strata_draft",
"workflow_author", "workflow_runner"]
# never "strata_commit"
strata_commit is what a live-model write needs; the session key never carries it, so the agent's own credential is tier-2 by construction. Default TTL is 28,800 seconds, a beat task sweeps expired keys every 300 seconds, and closing a session revokes the key, ends the row and drops the Redis pre-approval set.
Building this surfaced two pre-existing bugs: roughly 54 Strata proxy routes carried no requires_scope decorator, and the kinetic audit logger hardcoded actor="internal:agent_api", so every audit row named the service, not the person. Fix: a context variable plus an X-Strata-Actor header _audit_kinetic accepts, two functions changed rather than 54 call sites.
Three tiers, and the server decides which one you are in
The tier map is a declarative table of (METHOD, PATH_REGEX, TIER, COMMIT_CLASS), compiled at import, first match wins, kept in middleware and keyed off the scoped key, never prompt guidance:
# tier-1: reads, resolve, previews, dry-runs
("POST", r"^/(api/)?knowledgebase/[^/]+/ontology/(resolve|query)$", 1, None),
# tier-2: draft/branch-scoped writes
("POST", r"^/(api/)?knowledgebase/[^/]+/ontology/corrections$", 2, None),
# tier-3: always-confirm commit classes
("PUT", r"^/(api/)?knowledgebase/[^/]+/ontology/instance-keys$", 3, "instance_keys_active"),
("POST", r"^/(api/)?knowledgebase/[^/]+/strata/(reextract|backfill|ingest/.*)$", 3, "backfill_start"),
# fail-closed default: unmatched write -> (3, "uncatalogued"); unmatched GET -> 1
What each tier means:
| Tier | Examples | Behaviour |
|---|---|---|
| 1 | ontology reads, resolve, query, typed-action preview |
executes immediately |
| 2 | branch-tagged corrections, draft monitors, draft agents, canvas edits | executes if the context is untainted |
| 3 | apply-to-active, ontology promote, monitor activate, receiver CRUD, backfill start, Strata enable | parks in the approvals inbox |
Tier-2 is where the branches live: branch-tagged correction directives, draft monitors and dashboards, and shadow views, capped at five live branches per knowledgebase with a 30-day time to live. Documents, vectors and graph are never forked, and main stays byte-untouched until a promote is approved.
Two routes are tier-2 by shape and tier-3 by payload: the gate reads the buffered body for two checks, a corrections POST with no branch_id (correction_apply_active) and a monitor PUT flipping enabled truthy (monitor_activate). Anything unparseable escalates; the body buffer is capped at 65,536 bytes, and an oversized request gets a 413.
A tier-3 hit doesn't execute or fail: it writes a row to the v3 execution ledger with a frozen manifest, hashes it, and answers 202 awaiting_approval with the ledger id. The manifest holds the exact HTTP call, a human description, and any warnings, including cost estimates. Nothing is re-planned on approval; the dispatcher replays the frozen bytes.

PUT .../config/strata), requester system:fde:fde_23ffa0445e22, the exact body, and ledger id led_85624539164c.On approve, a Celery task claims the row, verifies the manifest digest, mints a 300-second single-purpose JWT bound to that method and path, and dispatches through the agent_api middleware chain. A 2xx marks the effect applied and the row executed; a 4xx is a confirmed refusal and the row fails; a 5xx or a timeout leaves the effect at dispatching and the row manual_review_required: a write may have landed, and inventing success or failure would be worse than saying so.
Self-approval is on by default, and that is a deliberate trade
Approvals shipped with STRATA_ALLOW_SELF_APPROVAL defaulting to false: right for a multi-operator organisation, useless for a single-admin team. We flipped the default to true, with an amber banner naming the environment variable that turns it off.
The ledger still shows the real lineage: the requester on an FDE-parked row is system:fde:{session_id}, the approver a named human, distinguishing an agent-initiated action from a human-initiated one. Set it to false and every approve by the requester returns a 409, forcing a second user to decide.
The compose plumbing has to be ${STRATA_ALLOW_SELF_APPROVAL:-true}: an empty-string passthrough makes getenv return "", silently re-disabling self-approval with no error anywhere, noted in the FRD as an assumption so nobody re-introduces it.
The agent cannot widen its own permissions either. Session pre-approvals, which grant a tier-3 class for the rest of the session, run through a JWT-only, owner-only route with no requires_scope, so default-deny refuses the session key itself. The FDE can ask you to pre-approve a class in chat; it can't call the endpoint itself.
The context register, and a signed record of what it saw
Boot context is deliberately thin: the resolver serialisation, the knowledgebase card and the pipeline run status. Everything else is dragged in by you or proposed by the agent for approve-to-add, and raw documents enter as retrieval handles rather than full text.

GET /api/fde/sessions/{id}/exposure assembles the session row, every context add/remove event, and a summary of the tool-audit rows into canonical JSON with a detached signature block: HMAC-SHA256 over the canonical bytes, keyed on the deployment's AUTH_SECRET. The route is JWT-only, so the session key can't export its own register.
That signature is worth less than it sounds. HMAC uses one shared secret: anyone who can verify the register can forge one. It proves integrity inside your deployment, nothing to a third party who doesn't trust you already. An RS256 upgrade over the licence keypair is a registered follow-up, not shipped: fine for a sovereign box, not enough for a "prove it to the regulator" artefact.
Transcripts store tool calls, truncated results at 32 KB per event, plans, approvals and context-set changes, not full LLM prompts: pinned documents get embedded in prompts, turning the transcript table into an unmanaged copy of the customer's documents. Full-prompt capture exists behind a debug flag, off by default. Retention: 365 days for audit rows, 90 for transcripts, on a daily batched sweep.

Injection gets its own rule: document-derived context blocks are marked untrusted, and any tier-2 action traceable to untrusted context escalates to tier-3 with the commit class tainted_draft, which pre-approvals don't cover. The taint header is asserted by our orchestrator, so an external MCP client can omit it. That's tolerable only because the session key carries no strata_commit and tier-3 never depends on taint: the worst an omitted header buys is an un-parked draft on a branch. I would not describe it as a boundary.
No frontier model in the registry, checked by a lint
The original design had an outbound path to a frontier model for the hard reasoning turns. We inverted it: a sovereign deployment that phones out to Anthropic for the difficult questions is a normal SaaS product wearing a sovereign badge.
There is no ANTHROPIC_API_KEY anywhere, no certant/fde-frontier alias, and no Claude, Opus, Sonnet or Haiku identifier permitted in the FDE model registry. Enforced twice: a validator runs on every registry write, and a repo lint runs the same validator over the shipped defaults plus a static scan of docker-compose*.yml, the customer bundle files and the LiteLLM config for a non-empty key.
_FRONTIER_DENYLIST = re.compile(
r"anthropic|claude|opus|sonnet|haiku|fde-frontier", re.IGNORECASE)
The in-product drawer runs on the deployment's own serving: Qwen3.6-35B at 262K context as sovereign primary, gpt-oss-120b supported with a mode set budgeted to its 65,536-token window, at least half reserved for conversation and tool results. FDE traffic is exempt from the LiteLLM response cache; turns bill at 0.5 credits base times the model multiplier, itemised as usage_fde, against a per-session ceiling the drawer stops at.
Pointing Claude into your instance instead
Frontier reasoning is still available, with the direction reversed. Generate a scoped key in the admin UI, run the version-pinned MCP sidecar image, and Claude Code drives the same tool surface the drawer uses:
"args": ["run", "--rm", "-i",
"-e", "AGENT_API_URL=https://api.<host>",
"-e", "MINDLATTICE_API_KEY=mlk_<scoped key>",
"ghcr.io/certant-ai/mindlattice-agent-mcp:v<PLATFORM_RELEASE>",
"serve", "--transport", "stdio"]
Same tools, same tier gate, same ledger, same audit rows. An external client has no orchestrator of ours, so the middleware appends tool_call and tool_result events to the transcript, keeping the exposure register complete. A tier-3 call returns 202 and the client polls until a terminal state: awaiting_approval, resume_pending, executed, rejected, expired, compensated, manual_review_required.
An authorised scoped key still returns row-level data to whoever holds it, even though Certant makes no outbound call and holds no frontier credential. The mitigations: the key's scopes, the tier gate, the exposure register, and the clamps on every tool result, 20 rows, 2,000 characters per field, 16 KB total.
What the gate did under test
The backend chain runs as a live shell suite against an isolated vmoat VM, and it passed 14 of 14 on 20 July 2026:
| Step | Assertion | Result |
|---|---|---|
| A2 | JWT exchanged for an ephemeral scoped key, PG round-trip | session fde_3fa275afadd4 opened |
| A3 | tier-1 read with the session key | 200 |
| A4 | tier-3 write with the session key parks | 202, ledger led_0ae38548eb87 |
| A5 | inbox shows the row plus self_approval_enabled |
true, surfaced |
| A6 | requester's owner self-approves | accepted |
| A7 | dispatcher reaches a terminal state with a real effect | executed, strata_enabled flipped true |
| A8 | audit rows plus signed exposure register | SIG_OK True |
| A9 | session closed, key rejected afterwards | 404 (anti-enumeration shape) |
The eval suite is the more useful evidence: it includes runs where the agent did badly. Across twelve recorded rows in fde_eval_results.jsonl, tier_violations is 0. Both T1 runs, building a working ontology on a fresh knowledgebase, met their goal with no human rescue, in 231.5 s and 273.7 s at 1.5 and 1.0 credits. The rest went worse: three T3 agent-build runs missed their goal, three T4 runs ended failed_partial after a rescue at 17.5 to 20.0 credits, and four T5 accuracy runs measured 0.0 uplift. The gate held throughout: a confused agent burning 20 credits on a bad plan still couldn't commit anything without a human clicking Approve.

Nobody has run an adversarial pass at the tier gate: no scored red-team attempt at prompt-injected escalation, no fuzzing of the two body-conditional escalation checks, and third-party verification of the exposure register is ruled out by the HMAC choice. The 12-row eval measured whether the gate leaked under normal and failing operation, not whether it holds against someone trying to get through it.
If you want to see the gate rather than take my word for it, open the FDE drawer on a demo knowledgebase and ask it to enable Strata. It parks a card in the approvals inbox with the exact call on the front, and waits for you.



