All posts
AI FDECertant StrataSovereign AIApprovals

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.

Daniel Voyce··10 min read

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".

Certant admin UI in dark mode with the AI FDE drawer open on the right, bound to the Aruma Disability Services EA knowledgebase, showing the greeting and a branch chip reading main
The drawer bound to a knowledgebase, captured 22 July 2026. The header chip shows the branch; the footer shows which model runs next.

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.

Certant approvals inbox showing an amber self-approval banner and a parked api_action card for a Strata config change, requested by system:fde, with the frozen JSON body strata_enabled true and Approve and Reject buttons
The approvals inbox with a parked FDE action, captured 20 July 2026: the frozen call (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.

The AI FDE Context tab showing a context budget of 45 of 32,768 tokens, boot context containing only the knowledgebase card, an empty added-context list and an Export exposure register button
The Context tab at the start of a session, captured 23 July 2026: boot context is the KB card alone, 45 of 32,768 tokens, nothing added yet, export button below.

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.

The AI FDE Audit tab listing four successful tool calls (get_run_status, list_views, resolve_question, query_data) followed by the model's answer table of four underpaid employees, with a Transcript download button
The Audit tab after a two-turn session, captured 23 July 2026. Every tool call is a classed row with its outcome; the Transcript button downloads the raw record.

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.

The Certant approvals inbox after approval, showing the self-approval banner and an empty state reading nothing awaiting approval
The same inbox after the self-approve, captured 20 July 2026. The card cleared and the action went to the dispatcher.

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.

Build a brain for your business.

Certant turns your documents, data and processes into agents, dashboards and assistants you can actually trust.