Skip to main content

The rule

Preparing an action must not cause its external effect. External effects occur only through explicit commitment.
This may be the single most important rule in Sense’s design — the thing that most separates it from “Python with an agent keyword.” A plain function call and a world-changing operation look identical in most languages: both are just foo(x). Sense makes them structurally different.

Declaring an action

reversible/irreversible replaces fn for anything that changes the outside world — marking the distinction at the declaration site itself, not bolted on afterward. The distinction is enforced, not just recorded on .kind: a reversible action must declare a rollback block (an irreversible one is rejected if it tries to), and only a reversible action can ever be rolled back — see Rollback below.

Calling an action never runs its body

This is the part that’s actually enforced, not just documented. Calling send_mail(...) returns a SenseAction — bound arguments plus a state — and nothing about that call touches the function body at all:
Only .commit() executes the body.

Lifecycle

Every step — .verify(), .approve(), .commit(), .rollback() — is a method on the action itself, one consistent calling convention. Skipping a step is an error, by design:

.verify() and .commit() each independently check policy

Verification is never a permanent guarantee — the classic time-of-check-vs-time-of-use problem. .commit() doesn’t trust an earlier .verify(); it checks again, itself.

requires approval: mandating a human sign-off

requires accepts a capability, the literal word approval, or both (comma-separated, in either order):
requires approval puts the requirement on the declaration — it can’t be forgotten by calling code the way a hand-rolled if ask_human("approve?") == "yes": a.commit() check could be. <action>.approve() is a separate, explicit method, so the caller still decides how approval is obtained — commonly ask_human inside an agent, but it could just as well be a programmatic check or an external process:
.commit() checks requires approval the same independent, immediately-before-the-effect way it already re-checks capability — not trusting anything decided earlier — raising SenseApprovalError if .approve() was never called. .verify() deliberately does not check approval, so the natural order is prepare → verify (capability’s fine) → seek approval → commit, rather than forcing approval before verification.

Audit log

Every prepare/verify/commit/deny/fail event on every action is recorded:
Interpreter.audit_log (host-side — a Python list of AuditEntry records with action_name, kind, event, detail, line, timestamp, lineage) is always populated, whether or not a Sense program ever reads it. The audit_log() builtin gives Sense code a simplified read-only view — an Array<String> of human-readable lines, since there’s no structured record/map value type yet to hand back the full entry. It’s in-memory only, scoped to one Interpreter instance — nothing is written anywhere durable. lineage records which agent/tool/action call an event happened inside of, outermost-first (e.g. ["agent:researcher", "tool:outer"]) — empty for a plain top-level call. describe() only appends a (via ...) suffix when it’s non-empty, so top-level events read exactly as above; sense inspect’s Audit log panel uses it to render nested calls as a call tree instead of a flat list.

Rollback: undoing a committed action

A reversible action must formally define its own undo, by declaring a rollback block — a reversible action with no actual undo is a claim the declaration doesn’t back up; irreversible is the honest spelling for “this can’t be undone,” so it’s rejected at parse time rather than surfacing later as a runtime surprise. <action>.rollback() runs the block nested inside the exact scope .commit()’s body ran in — so it sees not just the original args, but any local variable the commit body itself computed (an inserted row’s id, the value being overwritten):
The declaration (rollback:) and the method that runs it (.rollback()) share one name on purpose. rollback is still a plain identifier, not a reserved keyword — recognized only by name and a following : (never () — there’s no bare call left to preserve by staying one, but a smaller ambiguity remains regardless (a variable literally named rollback declared right after a reversible action’s body), rare enough to keep accepting rather than reserve a word used in exactly one grammar position.
rollback is required on a reversible action and rejected at parse time on an irreversible action — it “cannot be reliably undone”. <action>.rollback() requires state == "committed".
.rollback() re-checks the action’s capability, same as .commit(). A denied rollback leaves state at "committed" — nothing changed, so nothing should look changed. A rollback body that itself raises is surfaced as an ordinary error, state left at "committed" — there’s no auto-retry for a partially-failed rollback.

Dry run: preview what would commit without letting it happen

--dry-run uses the same prepare → verify → commit line this whole page is about: everything through .verify() is already just a check, and the real effect lives entirely inside .commit()’s (and .rollback()’s) body. Dry run intercepts exactly there — every capability/approval check still runs for real, only the body is skipped:
.commit()/.rollback() return nil in dry-run mode. Code that branches on a commit’s return value will behave differently in a dry run than a real run — the same unavoidable limitation terraform plan/kubectl --dry-run have, not something specific to Sense.
Scoped to action only — a tool call always runs for real, dry-run or not. Tool has no prepare/verify/commit staging to intercept, and the language’s own guidance is that anything world-changing belongs in an action, not a tool — dry run reuses a boundary the language already enforces rather than adding a second one. sense inspect --dry-run applies the identical flag to the live console, with an always-visible “DRY RUN” badge so a click on Commit is never ambiguous about whether it’s real.

Continue

Policy

requires and policy: allow/deny — capability-gating an action.

Tool

A capability-checked function with no prepare/verify/commit lifecycle — and the way to close the gap on what an action’s body itself calls.

Pause & Resume

ask_human, the usual way .approve() gets called.

Roadmap

What’s still missing from the full Action model: capability cost/risk dimensions, resource budgets, durable audit logging.