Skip to content

Control and Evidence

The model proposes. The runtime decides what runs, and keeps a record anyone can check. Every call to a connected provider (GitHub, Slack, ERPs and the rest of the catalogue) and every other effect an agent has outside its step (code it runs, commands on the host, files it publishes, changes it exports, memories it keeps, messages to other agents, the result it delivers) is decided before it happens and recorded.

flowchart LR
    A["Agent proposes<br/>(provider call, code, browser,<br/>memory, peer message, result)"] --> P{"1. Whom it is for:<br/>mandate and scopes"}
    P -->|beyond them| R["Refused before it happens"]
    P -->|within them| D{"2. Deployment rules<br/>and built-in rules"}
    D -->|allow| E["The effect happens"]
    D -->|refuse| R
    E --> J[("Hash-linked journal")]
    R --> J
    J --> V["jarviscore verify"]
    J --> RP["jarviscore replay"]
    J --> X["jarviscore evidence"]

What a person may do (their mandate and scopes) is decided first and holds in every mode; deployment and built-in rules then decide the rest, refusing in strict mode and recording in report mode. Every decision, allowed or refused, is in the journal.

What Where
Every provider call is decided on before anything leaves NexusCallProxy
What an integration's code can send, read from that code jarviscore.control.operations
The decision rules jarviscore.control.policy
Every other effect, decided where it happens jarviscore.control.effects
The record of decisions and calls jarviscore.evidence.journal
Checking the record jarviscore verify

To adopt it, follow Turning On Control; every key of the policy file is in the Control Policy Reference. The exact claims, their assumptions and the tests behind them are in claims/.


Modes

CONTROL_MODE sets what happens to a call policy does not allow.

Mode Decides Records Refuses
off No No No
report (default) Yes Yes Only what the person the work is for may not do (outside-mandate, scope-missing, over-scope-limit); anything else goes through, marked as one strict mode would refuse
strict Yes Yes Yes, before any network I/O, including the credential lookup

Start in report, read what strict mode would have refused (jarviscore policy review groups it by rule and target, with what to do about each), then switch to strict.

In strict mode a decision that cannot be recorded refuses the call too: the promise is that every call that leaves has its decision on record. (Switching the journal off, JOURNAL_DIR=off, gives that promise up: decisions are then made but not recorded.)


What is decided

Each call is built from what is actually about to be sent (provider, method, URL) and the authority it arrives with: the integration (atom), its declared effect, and where its code came from.

Where the code came from Trusted to Held to
shipped: an integration in the JarvisCore package Run as written; reviewed, statically checked The methods its declared effect allows. A read may POST (search and query endpoints), never PUT, PATCH or DELETE; only a destructive integration may DELETE.
runtime: the framework's own transports (the Slack room, workspace sources) Run as written The effect each declares (notify, read)
registry or model: code the model wrote or repaired, or a stored integration Nothing beyond what its code verifiably sends Every mutating call must be one of the operations its own code sends, read before it runs, within its declared effect
direct: an application calling the proxy itself Reads A mutation must come from an atom, unless a deployment rule lifts that for a named provider

Built-in refusals:

Rule Refuses
mutation-without-atom POST, PUT, PATCH or DELETE that does not come from an integration with a declared effect
beyond-declared-effect A method beyond the integration's declared effect (a write that deletes, a read that updates)
outside-contract Model-written code sending an operation its own code does not contain
unverified-model-code Model-written code mutating when its operations cannot be read statically

Operation contracts

read_operations(source, entry) reads, from an integration's own code, every operation it can send through nexus_call: method and URL template. It follows module constants, local variables, closures and helper calls from the integration's entry function, and prunes dispatcher branches the call can never take (if method == "get": ...).

Across the catalogue shipped with 2.4.0:

  • 914 of 1,186 integrations have a fully verified contract.
  • The rest build a URL or method at run time (request signing, RPC-style APIs where the operation is in the body). They are held to their declared effect and recorded as unverified.
  • No shipped integration contains reachable code that can send beyond its declared effect. A test checks this on every build (claims/shipped-atoms-within-declared-effect.yaml).

Deployment rules

CONTROL_POLICY_PATH names a JSON or YAML file of rules. A deny rule refuses more; an allow rule can lift only the atomless-mutation refusal, for code the deployment owns. Nothing a deployment writes can let model-written code send what its own code does not.

rules:
  - name: no-payments-from-agents
    decision: deny
    match: {provider: stripe, effect: [write, destructive]}
  - name: no-deletes
    decision: deny
    match: {method: DELETE}
  - name: our-crm-client
    decision: allow            # our own reviewed client calls the proxy directly
    match: {provider: hubspot, code_origin: direct}

Match keys: provider, atom, effect, method, code_origin, verified. A list matches any of its values. A rule with any other decision is treated as deny, and a file that cannot be read refuses every call.


The journal

Each workflow step is one run file of JSON lines under JOURNAL_DIR (default .jarviscore/journal). For every provider call:

Event Written Holds
policy.decision Before anything leaves Provider, method, host, path, integration, effect, code origin, whether its contract verified the call, outcome, rule, whether it was enforced
effect.attempt Just before the request The call id
effect.outcome After the response, or the failure Status code or error type, whether the credential was refreshed

Never query strings, bodies or credentials.

Every event carries the hash of the one before it and its own hash (SHA-256 over its canonical JSON: sorted keys, no whitespace). A separate head log, also hash-linked, registers each run's latest event as it is written.

Checking it

jarviscore verify                    # this deployment's journal
jarviscore verify /path/to/a/copy    # a copy handed to an auditor; no keys, no network
jarviscore verify --json
Detected How
An edited event Its content no longer matches its hash
A removed or reordered event The chain or the sequence breaks
A run cut short The head log registered a later event
A removed run The head log registered it
A provider call with no decision before it, or one policy refused Each effect.attempt is checked against its policy.decision

verify also prints notes that are not tampering: a run still being written, or events after a run's registered head from an interrupted write.

Per person and per tenant

jarviscore evidence acme/u_123                      # what agents did for one person
jarviscore evidence acme/ --export acme-evidence/   # a tenant's runs, for its auditor
jarviscore verify --export acme-evidence/

An export holds the runs exactly as written, plus the deployment's head log, so each run is still checked against what the deployment registered. Runs that also worked for someone else are left out. A plain verify refuses an export, so a whole journal cannot pass as one with runs missing. See Agents That Act for Your Users.

Not yet detected: a rewrite of the whole journal, head log included, by whoever controls the directory. That needs the head log anchored outside the deployment (signed checkpoints, independent witnesses), which is the next step for the journal.


Approvals

Consequential actions wait for a person (Human-in-the-Loop). What holds for them:

Property How
An approval covers the exact action the person saw Bound to every argument (or the element and values a browser action touches); other arguments ask again
The institution decides which actions need a person, and who may approve approvals: rules: roles, not the requester, least assurance; waivers never for destructive actions
The approver is who they say Approval assertions signed by your backend over the exact action (verified)
Policy is checked when the action runs, not only when it was approved A tightened policy sends the action back to a person
At most once The first decision stands; starting the action is claimed atomically; an interrupted run leaves OUTCOME_UNKNOWN for a person to reconcile
A gated call cannot reach a provider without its approval In strict mode the broker refuses approval-missing (report mode records it); jarviscore verify checks every call carrying an approval against an approval of that exact action
Retries do not repeat a payment Providers that deduplicate by key get the operation's id (Stripe, Adyen, Mollie, GoCardless, Checkout.com, PayPal headers; Square's idempotency_key)

The journal records approval.request, approval.decision (approver, roles, assurance, how they were authenticated), approval.refused, approval.claim, approval.settle, approval.reopened and approval.reconciled.


Replay

Every agent run keeps, in its step's journal, what its decisions depended on from outside its own logic: each model request and response, what each gate returned (approvals, previews, reviews), what each tool returned, the task, context, tools, budget and versions it started with, and where its loop started (a resumed run starts from its checkpoint). The chain holds their digests; the content is kept beside it, content-addressed, so it can be erased (a retention limit, a person's right to erasure) without breaking jarviscore verify.

jarviscore replay                  # every recorded run
jarviscore replay journal/ wf-12/pay
wf-12/pay: reproduced (2 decision(s), 3 model call(s), 3 request(s) identical to the record)

A replay rebuilds the agent (by its class name; an agent class defined in a script or inside a function needs replay(root, run, factory=...) from Python) and answers every model call, gate and tool from the record, under the person, mandate and scopes the run had. A gate's own model calls (an effect reviewer's) are played back with the gate. Nothing is called and nothing acts. A run reproduces when the agent makes the same decisions (each tool, with the same arguments) and ends the same way. It also says how many model requests were identical to the record, with the runtime clock masked, the one thing a replay cannot reproduce. A run whose agent logic, prompts or record changed diverges, and the report says at which decision. Content that was changed fails its digest.

JOURNAL_CAPTURE sets what is kept: full (default, replayable), digests (verifiable, not replayable) or off. A coder step keeps about 70 KB. Content unused for JOURNAL_CONTENT_DAYS (default 30; 0 keeps it) is erased in the background, and jarviscore evidence <person> --erase --yes erases one person's; either way the journal still verifies. A run records whom it worked for, and replays as that person.

What replay does not do: run the model again. It shows that the recorded inputs lead to the recorded decisions under the agent's code, not what a model would answer today.


Acting for a person

An agent working for someone uses that person's own access, within what they handed over, and every record names them.

Property How
Their credential, nobody else's JarvisCore names the person on every credential request (X-Nexus-Subject, with the agent and operation); Nexus releases a credential only for the person whose connection it is. With Nexus SUBJECT_BINDING=required, a connection id alone releases nothing. A credential Nexus refuses is not replaced by any other, and one cached for one person is never served to another
Never more than they could do It is their credential, so the provider holds the agent to that person's own permissions
Within what they may do in your organisation Your scopes: which operations need which scope (scopes.require, matched on the operation, with limits checked on the request as it leaves) and which the person holds (OnBehalfOf(..., scopes=...) from your roles). Approving can need its own scope (approved_by). A call or browser page beyond them is refused (scope-missing, over-scope-limit), and what the person can never do is shown to the agent as refused, never run. See the guide
Within what they delegated A mandate registered with the work narrows it further: the systems, effects, scopes and time the person handed over. A call outside it is refused (outside-mandate); no deployment rule widens it
On record Every decision in the journal names the person (acting_for), the mandate's digest and the scopes it was checked against; Nexus's audit names the subject, agent and operation of every credential it releases or refuses
In every mode but off What a person may do (outside-mandate, scope-missing, over-scope-limit) is enforced in report mode too: your application said what they may do
from jarviscore import OnBehalfOf

ada = OnBehalfOf.of("acme", "ada", mandate={
    "systems": ["erp"], "effects": ["read", "write"], "expires_at": "2026-11-01T00:00:00Z",
})
await mesh.execute_goal("Pay supplier invoice INV-7", on_behalf_of=ada)

run_task, workflow, submit_goal and create_chat_router take the same on_behalf_of, and act_for covers a block of your own code. Whom work is for comes only from your application, never from what a client sends or an agent writes, and holds for every step, sub-agent and peer of the work on any node, including workflows an agent builds while doing it. A mandate that cannot be read refuses every call. For applications with their own users and tenants, see Agents That Act for Your Users.

What this does not do: Nexus binds a credential to the subject your runtime names; it does not authenticate the person itself. Keep the gateway key with the runtime, and authenticate people where they ask for work.


Every other effect

What an agent does outside its step that is not a provider call goes through one decision point at the place it happens, recorded the same way (policy.decision, effect.attempt, effect.outcome; sizes and digests, never content), so jarviscore verify holds each to "decided before it happened".

Sink Where Records
code Each program run in the sandbox: model-written code, registry and shipped integrations, browser cells Origin, digest, whether the child was confined
process A command on the host, outside the sandbox's child The programs, digest
artifact A file the sandbox publishes to blob storage Key, size, digest
workspace A step's changes exported for the next step or for review; a file an agent writes on the host Counts, digest; path
memory A fact kept for later sessions Kind, private or shared, digest
peer A message to another agent: send, delegate from sandboxed code, and the engine's broadcast of every step result to all peers Recipient, digest
output A step's result delivered to the steps and caller after it Status, digest
browser A page's top-level navigation, and every request carrying data out of the browser (a form, fetch, XHR, a beacon) Method, host, the page's host, digest of the body

Commands a confined child runs inside its own workspace are its scratch work: they cannot reach the network or write outside the workspace, and what they produce leaves only through the sinks above.

Strict mode needs a confined sandbox. Model-written code, registry integrations and host commands the model chose are refused (unconfined-execution) when they would run outside a confined child, since there they reach the network and the host past every broker. Integrations shipped with JarvisCore still run. Where confinement is not available (macOS sandbox-exec, Linux bubblewrap), a deployment that accepts the risk says so with a rule, which is then on record with every run it allows.

effects:
  - name: no-broadcast-of-results     # keep step results off the P2P network
    decision: deny
    match: {sink: peer, action: broadcast}
  - name: no-exports
    decision: deny
    match: {sink: artifact, target: "*/exports/*"}
  - name: ci-runner-without-bubblewrap
    decision: allow                   # lifts only unconfined-execution
    match: {sink: code}

The browser sends only to the site it is on. In strict mode a page's request that carries data (any method but GET, HEAD and OPTIONS) to a host other than the page's own is refused before it leaves (browser-send-off-site): a script an attacker planted, an instruction that made the agent fetch somewhere, a third-party beacon. Allow the hosts your sites really send to, as target globs over host and path:

effects:
  - name: our-app-api
    decision: allow
    match: {sink: browser, target: "api.example.com/*"}

Requests are intercepted in strict mode (Playwright then disables the browser's HTTP cache) and only observed in report mode, at no cost, unless a scope names a browser target: then they are intercepted in report mode too, so a scope's refusal holds. Plain reads (images, scripts, GET data) are neither decided on nor recorded, except reads of a target a scope names (a frame or fetch included), which are decided like a navigation. Strict mode blocks service workers in contexts JarvisCore creates. Not covered: WebSocket and WebRTC traffic, and data a GET request carries in its address.

Match keys: sink, action, origin, agent, target (a glob) and confined. A rule that cannot be read refuses every effect. A test enumerates every raw call in the package that runs a process, stores a blob, keeps a memory, messages peers or writes a file from an agent tool, and fails the build if one is neither decided on nor classified as the runtime's own.


Grounding

An approval asks a person. Grounding asks the system of record. For an operation such as a payment, the deployment names which fields of the request leaving for the provider must come from records the broker itself read earlier in the same workflow. The model chooses the call; it cannot choose those values. An IBAN planted in an invoice's notes, a "we changed banks" email in the task, or a person who approves without reading: the payment still goes only to the account on the supplier record.

grounding:
  - name: supplier-payment
    match: {provider: erp, method: POST, path: /payments}
    bind:                                   # follow records, in order
      - name: invoice
        read: {provider: erp, path: "/invoices/{id}"}
        where: {id: request.json.invoice}   # which invoice this call pays
      - name: supplier
        read: {provider: erp, path: "/suppliers/{id}"}
        where: {id: invoice.supplier_id}    # that invoice's supplier, not any supplier
    require:
      - {field: request.json.iban, equals: supplier.iban, as: iban}
      - {field: request.json.amount, at_most: invoice.amount_due, as: money}
    other_fields: deny                      # nothing else rides along
Property How
Sources are what the broker received Only provider responses the broker recorded count; code cannot hand it a record
Relationships, not membership A binding selects one record, and the next follows it; a value seen elsewhere in the run proves nothing
What leaves is what is checked The body httpx will send (content over data over json); a body grounding cannot read is refused
Typed comparison iban (ISO 13616, check digits), money and number (decimal), email (IDNA domain), text (exact after NFC, never case- or lookalike-folded), id, exact
Ambiguity is refused Two records answering one selector, a field sent twice, a field missing
What the run changed is not a source A record read after this workflow wrote to it grounds nothing
Which record can be pinned selectors_from: [input] takes selectors only from the caller's structured input (execute_goal(context=...)), never from the request
Fresh at the moment of acting fresh: reread reads each record again just before the call; max_age_seconds bounds how old a read may be
Every grounded call cites its reads The decision records each record it relied on (the journal event of that response and its digest); jarviscore verify checks every citation

Before a person is asked. When a grounding rule could apply, the action is previewed first: the integration's own code runs with every change held at the broker after its decision, before anything leaves (reads run as usual). If strict policy would refuse it, nobody is asked and the agent gets POLICY_WOULD_REFUSE with the reasons. Otherwise the approval request carries what was checked (payload.checks: each change, its decision, and the records it was grounded on), so the person sees, for example, that the IBAN is the one on the supplier record. Each previewed change is journaled as policy.preview, and jarviscore verify counts those refused before approval. The check at the moment of sending still decides.

Relations: equals, one_of (a list, or a path with [*]) and at_most (money or number). A refusal names every requirement that failed. A rule that cannot be read refuses the calls it matches; one whose match cannot be read refuses every call. Values are recorded as digests, never in clear.

What grounding does not do: make a system of record correct. A supplier record an attacker already changed is still the record. Unless selectors come from the caller's input, the model still chooses which invoice, within what the records allow. Records are kept per process, so a workflow whose read and payment run in different processes is refused (fail closed).


Performance

Deciding and recording adds about 0.1 ms to a provider call. Grounding costs nothing when no rule applies: responses are kept only when they can ground a rule. Where one does, the preview before an approval runs the integration once more in the sandbox with its changes held (about 0.5 s measured), once per action. Provider calls now reuse their connection (NEXUS_KEEPALIVE_SECONDS, default 120): on a measured HTTPS endpoint a call went from about 500 ms with a fresh connection to about 90 ms on a kept one.


Not covered yet

WebSocket and WebRTC traffic from a page, and data carried in a GET request's address, are not decided on. Signed, witnessed checkpoints come next, each with its own claim and tests.