ADR 0020: Jev as egress primitives; register-check
- Status: accepted; implemented in P5 (
v0.6-plan, 2026-09-22); amended 2026-09-30 (the model's judge tools) and 2026-10-01 (Seam C answers confirms); amended by ADR 46: one set of primitives,judge-yes-no,judge-scoreandjudge-choice, with no handle, speaking the API of the judge's server (Jev or OpenAI's Decisions API) with its key - Date: 2026-09-21
Context
ADR 19 established that Jev is an egress edge: its payload passes the same
redaction controller as the model context, and every call is logged. The P5
plan, however, describes internal/jev as a Go client package that the
loop calls directly. This creates a second path for Jev calls that bypasses
the guard, the binding check, and the guard-decision log — even though
ADR 19 says the call should be treated as egress.
SPEC invariant 4: the Scheme interpreter is the only Scheme→Go surface; the guard sits on it. A Go-only Jev client called by the loop is a second surface for a data-plane egress.
The spec's LLM-as-tool split resolves the one apparent exception: the
harness's own LLM key is control-plane — it is the harness's identity,
standing, not grant/revoke, and not audited by Jev. The loop's Chat()
call is not a guarded egress; it is the harness talking to itself.
Everything else, including Jev, is data plane.
Decision
1. Jev is a set of Scheme egress primitives
The three Jev tools are registered as host primitives (Go handlers,
Meta.Egress: true), not as a separate Go client package called by the
loop:
| Primitive | Args (positional) | Purpose |
|---|---|---|
jev-noul |
(handle question) |
Yes/no gate: does this message need a plan? |
jev-score |
(handle payload ask) |
Score a plan against the user's ask (Seam A) |
jev-choice |
(handle situation) |
Score an ambiguous egress situation (Seam C: class + dest + scope) |
Each takes a credential handle as its first argument
(Param.Role: "credential"). The handle is the Jev API key, stored in
the keychain (P3) and bound to api.typesafe.ai (P3 binding table).
The remaining arguments are text payloads.
The loop's Seam A call is (jev-score handle plan-text ask-text),
evaluated through interp.Apply — the same path a model tool call takes:
guard intercept → binding check → redaction → handler makes the HTTP call
→ result returned as a Scheme value.
The loop does not call Jev through a Go-specific code path.
2. The guard sees Jev calls like any other egress
Because the Jev primitives cross the guard, none of the existing machinery is special-cased:
guard.decisionis logged for every Jev call: prim name, origin, verdict, checker, reason, args summary.- The binding check (E3) verifies the credential is bound to
api.typesafe.aibefore the call is allowed. Unbound ⇒ Deny (default) or Confirm (policy). - The payload is redacted before the handler sends it: L1 at P5, L1+L3 at P7 (ADR 19: no bespoke sanitiser).
jev.reqis logged with the Jev-specific fields: content level (jev.content), byte count, payload hash, redaction events, and the score that came back.
3. User tools can call Jev under the same policy
A user can register a tool that wraps a Jev call:
(register-tool "review-diff"
(lambda (handle)
(jev-score handle
(git-diff ".")
"is this change complete and safe for the task?"))
'((doc . "Ask Jev to score the current diff against the task.")
(params . (("handle" "credential" "Jev credential handle")))))
When the model calls review-diff, the guard sees the outer tool call
(origin: model, prim: review-diff, a Scheme-registered tool). The handler
applies
the Scheme lambda, which calls jev-score — crossing the guard a second
time as an egress prim with a credential. The binding check fires on the
inner call; the payload is redacted; the decision is logged.
The model can only call review-diff if the user has registered it.
The user is the principal who decides which Jev-backed tools exist.
The guard's policy for the inner jev-score call is identical whether
the call came from the loop's Seam A or from a user-registered wrapper.
4. register-check: tightening-only Scheme checkers
The guard's checker pipeline is currently Go-only. A user who wants to add
a custom guard rule must modify Go code. register-check closes this gap
without weakening the guard:
(register-check NAME VERDICT PREDICATE)
NAME— a string, logged as the checker name inguard.decision.VERDICT—"deny"or"confirm"(not"allow").PREDICATE— a Scheme function(prim-name args) → boolean. When it returns#t, the checker returns the specified verdict; otherwise it returns Allow (no opinion).
The checker is added to the guard's pipeline after all Go checkers.
The strictest-wins rule (deny > confirm > allow) already guarantees that a
register-check checker can only tighten: its Deny or Confirm stands
regardless of what another checker says, and its Allow (no-op) never
overrides a Deny or Confirm from any other checker.
register-check is a hidden builtin (Meta.Hidden: true): it is not
offered to the model as a tool, and the model cannot call it. Only
user-originated Scheme code (.scm files, init.scm, the REPL) can
register checkers.
5. The control-plane model is the one Go-only call
The loop's provider.Chat() is the harness's control-plane call to the
model. It is:
- Not a guarded egress. The control-plane key is the harness's identity (spec LLM-as-tool split), not a data-plane credential. It is not in the binding table, not subject to the binding check, and not audited by Jev.
- Not redacted at the call site. Redaction applies to what enters the context (E2/E4), not to the control-plane request itself.
- Logged as
llm.req/llm.resp, not asguard.decision/jev.req.
This is the only Go-only path in the loop. Every other call — including Jev — crosses the guard as a Scheme application.
Consequences
- P5 drops
internal/jevas a standalone client package. The Jev handler is a Go function registered as a Scheme host primitive (alongsideshell-exec,fs-read, etc. ininternal/prim/). The loop's Seam A is a Scheme application, not a Go function call. - The Seam A gate and the Seam C score are both guarded, logged, and subject to the same binding and redaction as any other egress.
- User tools that call Jev are indistinguishable from model tool calls at
the guard level; the
originfield in the log distinguishes them, but the policy is identical. register-checkextends the guard pipeline without modifying Go code. The strictest-wins rule ensures a user checker can never relax the guard.- The P5 "fake Jev" test in
test/fakestests the Scheme primitives (their guard decisions and redaction), not a Go package API. - The exfil suite gains a scenario: a Jev call with an unbound credential
⇒ Deny; a Jev call with a bound credential whose payload contains a
canary ⇒ the canary is redacted in the sent payload and in
jev.req. - There is no
internal/jev/: the Jev handler lives ininternal/prim/alongside the other host primitives.
Fixed request scope implemented — 2026-09-22
All three Jev primitives now declare EgressMethod: "POST" and
EgressPosition: "header:Authorization:Bearer" alongside their configured host.
The guard uses these Go-owned facts when there is no corresponding role
parameter. A declared argument always takes precedence, including malformed or
missing arguments; fixed metadata never hides an invalid dynamic argument.
This closes an authorization gap: a Jev row matching only class and host could previously authorize a request even if it listed GET or a query injection. Jev now requires the actual POST and Bearer-header scope. Request bytes and credential ownership are unchanged. Confirmations carry the same complete facts used by authorization. Tests exercise all three primitives with matching and mismatching method/position rows and assert no transport call on denial.
Amendment — 2026-09-30: the model's judge tools
The three primitives were offered to the model as tools, which it could never call: a handle cannot come from a model's JSON, so every call failed in the handler after the guard had allowed it. Now:
jev-noul,jev-scoreandjev-choiceare hidden. They keep the handle argument; the plan gate and user code hold one.- The model has
judge-noul,judge-scoreandjudge-choice: the same calls without the handle. Go looks up the storedjev-keyat call time, and the primitive'sEgressClassisjev-key, so E3 judges the call as carrying that class at the Jev host, POST,header:Authorization:Bearer, exactly the check ajev-*call gets (an MCP server's key works the same way, ADR 22 §4). With no key stored the call fails before anything is sent. - They are registered only when the session starts with a
jev-keystored, since the tool list is fixed for the session, and withjev.content = "full". ADR 19's levels say what may reach Jev; the model chooses what these tools send, which may be raw file contents or tool output, and nothing can hold that to a plan. At the defaultplanlevel only the plan gate talks to Jev. - Every argument of every judge primitive is a payload:
levelsandoptionshad no role, so L1 and L2 never read them, and the handler did not redact them. Now the guard reads them and the handler redacts every string it sends. jev.reqrecords the call's origin (harnessfor the plan gate,modelfor a judge tool,userfor:eval).
Section 3's example is out of date in one more way: a tool body runs with
the model's origin, so it cannot call credential; a user tool closes over
a handle looked up in init.scm instead.
Amendment — 2026-10-01: Seam C answers confirms
Seam C ("score an ambiguous egress situation") is used for judged approval
(ADR 18): the core asks jev-choice whether a call should run with nobody
watching, and allows it only above jev.approve_threshold. Unlike the plan
gate it is Go, because an approval must not be something a repository's
Scheme can grant: Scheme can only add refusals (approve-refuse?), as
register-check can only tighten the guard.