Effects & reconciliation
How Tarski touches the world — declared capabilities, admission identity, retry policies, the effect lifecycle, work queues, and explicit reconciliation.
Effects are downstream of semantic evaluation, always:
proposal.* → decision.approved.* → intent.* → effect request → attempt → receipt observation
Approved decisions derive intents; effect authority admits requests; the runtime executes declared capabilities; and results return only as receipt observations that re-enter the lineage as evidence. Guest code cannot mutate facts, append observations directly, or use undeclared ambient I/O.
Capabilities
| Capability | Purpose |
|---|---|
http.fetch |
Declared outbound HTTP |
llm.complete |
Explicit model calls for LLM-assisted agents |
blob.put / blob.get |
Content-addressed large payload storage |
timer.schedule |
A future timer observation |
log.dev |
Developer diagnostics only — never canonical state |
Capabilities are declared in tarski.toml and bound to intent relations. Admission
identity is the lineage plus the intent relation name plus declared identity key values —
which is what makes retries and crash recovery resolvable.
If a live binding’s credential reference cannot be resolved, the effect blocks with typed
http.credential.unresolved before the network is opened — no receipt is appended, and
nothing silently substitutes a deterministic provider.
Retries
Idempotent effects may auto-retry when the runtime classifies retry as safe. Non-idempotent or uncertain attempts enter reconciliation instead — policy never overrides the runtime’s safety classification.
Retry policies are declared per intent binding: fixed or exponential backoff, initial and
maximum delays, optional deterministic jitter, and exhaustion bounds (max attempts and/or
elapsed time) with on_exhausted = "failed" or manual reconciliation. Resolved policies
are canonicalized and digested at admission — later manifest edits can never rewrite the
policy an old decision ran under.
The effect lifecycle family
The runtime populates a reserved relation family your rules can read (never declare):
effect.queued, effect.in_flight, effect.succeeded, typed effect.failed,
effect.blocked, effect.reconcile_required, effect.conflict, plus grouping views
effect.open and effect.terminal.
Rules that derive an intent at head H can read that effect’s lifecycle evidence only at a later head — same-head feedback loops are structurally impossible.
Work queues
Work queues are operational dispatch control for already-admitted requests — “when may this start?”, never “may this happen?”. Queue policy (concurrency, partitions, ordering, lease timing, rate limits, saturation behavior) is frozen at admission with its own digest; operators adjust capacity by appending durable queue-control records, not by editing manifests. Inspect with:
tarski work-queue list
tarski work-queue inspect
Reconciliation
When an effect attempt is ambiguous — a crash mid-flight, an uncertain provider — the
runtime never silently guesses. The attempt parks as reconcile_required, and an
operator resolves it explicitly:
tarski reconcile inspect --session latest
tarski reconcile resolve <attempt-id> --outcome <succeeded|failed|retry>
The resolution appends as an observation, so the decision is part of the lineage — auditable, replayable, and visible in provenance like everything else. Manual retry keeps the original attempt counter and frozen policy metadata.
Typed HTTP receipts
Plugin-owned http.fetch bindings may declare a receipt_kind plus a request template,
producing typed, schema-validated receipt observations (e.g. a product-page fetch result)
instead of the generic effect.result. App-owned HTTP bindings emit generic
effect.result receipts in V1. result_kind belongs to llm.complete only. See
Plugins.
App-owned HTTP envelope, egress, and size limits
An app-owned binding may materialize the request from named fields of an object-shaped intent:
[capabilities]
http_clients = ["calendar_feed"]
[capabilities.intents."intent.calendar_feed_requested"]
capability = "http.fetch"
resource = "calendar_feed"
identity_keys = ["account_id", "feed_id"]
[capabilities.intents."intent.calendar_feed_requested".request]
method = "GET"
path = "/feeds/$feed_id"
query = { account = "$account_id" }
headers = { accept = "text/calendar" }
[resources.http.calendar_feed]
base_url = "https://calendar.example.com"
allowed_hosts = ["calendar.example.com"]
credential_ref = "CALENDAR_API_TOKEN"
tls = "https_only"
Template placeholders use $field_name and resolve only from scalar fields of the
canonical intent object. method, exactly one of path or url, query, headers,
body, and idempotency_key are supported. A JSON request body is an ordinary string
template, so quote/escape it as TOML text; complex nested assembly is better done before
the intent boundary and carried as one canonical string field.
If no request template is declared, the intent object itself must provide the concrete
method, path or url, optional query/headers/body, and optional idempotency key.
App-owned results return as effect.result observations. The capture includes request id,
attempt, correlation/admission identity, captured time, normalized request, response
status/headers/body, and an egress audit block. Your mapper must explicitly map the
fields the ontology needs; a non-2xx HTTP response is still a valid captured receipt.
Pre-capture network/configuration failures stay typed effect lifecycle diagnostics and do
not invent a result observation.
Egress policy is static:
- With no
allowed_hosts, onlybase_url’s host is admitted. - Entries may be exact hosts or suffix wildcards such as
*.example.com; the suffix does not include the apex. - HTTPS is required by default, redirects and environment proxies are disabled, DNS results are pinned, and metadata/private/local destinations are denied unless the resource explicitly opts into private networking.
- An owner-supplied URL may fill an intent field, but it cannot widen the resource allowlist. Use a platform-controlled host allowlist, a gateway/proxy resource, or a client-side fetch when tenants may enter arbitrary feed hosts.
Live HTTP response bodies are capped at 10 MiB before a capture is admitted. The selected
mapper’s payload_limit can impose a smaller bound, and tenant HTTP-egress quotas can
deny or pace traffic before that. For large durable content, use blob.put/blob.get
and carry a BlobRef rather than expanding the mapper input ceiling.