Protocol reference
The treasury and governance layer for AI agent organizations: an onchain org chart where every agent has a budget, overages climb an approval chain, and constitutional changes pass through governance humans ultimately control. This page mirrors the normative SPEC.md section for section; /protocol is the summary with deployed addresses.
Versions follow semver: breaking interface changes bump the minor pre-1.0. Reference implementations live in contracts/src. Five invariants hold everywhere, and every section below is one of them made concrete:
enum Verdict { ALLOW, ESCALATE, DENY } interface IPolicyModule { /// Evaluate whether `agent` may call `target` with `value` and `data`. function check(address agent, address target, uint256 value, bytes calldata data) external view returns (Verdict); }
A node's policy is a PolicyStack (itself an IPolicyModule) evaluating members in order: the first DENY returns immediately; otherwise any ESCALATE is sticky and returned; otherwise ALLOW. The reference modules:
| Module | Behavior |
|---|---|
SpendCapPolicy | Per-agent cap on value; over-cap → ESCALATE. Mutator setAgentCap is admin- or governor-gated. |
WhitelistPolicy | Unlisted target → DENY. Mutator setAllowed is admin- or governor-gated. |
RateLimitPolicy | Sliding-window action count per agent; over-rate → ESCALATE. The router records via IRateRecorder.record(agent) — into the node's own recorder when one is bound, else the global one. Params are constructor immutables: a different limit is a different module. |
TimeWindowPolicy | Outside the configured UTC window → DENY. |
Stacks bind per node through EscalationRouter.setNodePolicy(node, module) (governor-gated once a governor is set). A node whose stack carries its own RateLimitPolicy additionally needs setNodeRateRecorder(node, module) (same gating): recording is what fills the module's windows, and a custom rate module the router never records into silently never trips — the per-node recorder exists to make that failure impossible.
IPolicyModule is a public extension point: a guardrail nobody here wrote is bound the same way a shipped one is. To publish one:
check as a view function. It is called inside EscalationRouter.propose and inside every PolicyStack it belongs to, so it must not revert on inputs it does not recognise — return ALLOW and let another member decide. A module that reverts takes the whole stack with it. Keep it cheap: the same call is made on every proposal.policyModule listing whose payload is validated by validatePolicyModulePayload (@lacrew/flows): id, version, name, summary, the deployments (one {chainId, address} per supported chain), the slots the module is written for, and an audit claim. Absent audit metadata means unaudited, which is what the catalog labels it. A listing may instead name a standardModule — one a deployment already carries in its address book — which is how first-party entries resolve without hardcoding an address that is right on one chain only.Buying does not attach. A purchase settles USDC on MarketplacePayments and entitles the buyer to the payload; binding is setNodePolicy, which is governor-gated. The orchestrator's install path reads the stack the router binds for the node, appends the listed module, deploys the new PolicyStack (permissionless and inert), and proposes the bind at the high tier. Until that proposal executes the node keeps exactly the modules the org voted, and because appending puts the new module last behind them, a bought module can only ever narrow what the existing stack lets through — first DENY still wins.
Nodes are accounts (HumanRoot | ManagerAgent | WorkerAgent); edges are reporting lines. After a governor is set, structural mutators are governor-only — structure changes are constitutional actions.
function getNode(address account) external view returns (Node memory); function getChildren(address parent) external view returns (address[] memory); function addNode(address account, NodeKind kind, address parent) external; // governor function removeNode(address account) external; // children rewire to parent function reparent(address account, address newParent) external; // cycle-safe function setActive(address account, bool active) external;
Events: NodeAdded, NodeRemoved, NodeReparented, NodeActiveUpdated.
An org with two or more humans (agency partners, a club, a community-funded crew) is one tree, not a forest: the registry keeps its single root node and the additional humans are HumanRoot nodes parented to it. This is a deliberate choice over a virtual org above several roots — it needs no new contract, no second registry, and every existing walk (children, reparent, the cycle check) already handles it. The root node is unremovable, so the tree always retains at least one human.
Two things are being modelled and they are not the same:
| Where it lives | What it answers | |
|---|---|---|
| The chart | OrgRegistry node of kind HumanRoot | Who is drawn as a human, and who reports to them |
| The authority | GovernanceModule seat with SeatRole.Human | Who votes, who counts toward high-tier final say, who may veto |
A HumanRoot node does not by itself confer a vote, and a human seat does not require a node. In practice a partner gets both, and the two are seated by different calls — one is a tree write, the other a governance action (§6.1). Peer humans are peers in authority: no seat outranks another for veto. The humanRoot address keeps a narrow extra privilege (§6.1), and it keeps it only while it holds a seat.
The Treasury holds org funds; nothing pulls from it directly. Allowances stream downward per node; agents spend their allowance (via the router), never the treasury. EpochStreamer runs the schedule:
function setGrant(address node, uint256 amount) external; // operator or governor function runNextEpoch() external returns (uint64 epoch); // operator function recipients() external view returns (address[] memory);
Events: GrantUpdated, EpochRun(epoch, recipientCount). The treasury implements ITreasurySpender.spendAllowance(node, amount, to) for the router's finalize path.
A Treasury binds one immutable ERC-20. An org funds N assets by deploying one Treasury + EscalationRouter + EpochStreamer per asset over a shared OrgRegistry, so the org chart stays single while enforcement is asset-scoped: allowances stream and spend independently, a treasury never moves a foreign token, and pending escalations resolve only in their own asset's router.
Policy stacks are asset-denominated. SpendCapPolicy compares raw uint256 values, so a 100 USDC cap (100e6) is dust against an 18-decimal asset. Deploy a separate stack per asset; never share one across assets with different decimals.
Agents act by proposing intents. The router checks the agent's session key, then its policy stack:
function propose(address agent, address target, uint256 value, bytes calldata data) external returns (uint256 intentId, Verdict verdict); function resolve(uint256 intentId, bool approved) external; function setNodePolicy(address node, address policyModule) external; // governor function setNodeRateRecorder(address node, address rateRecorder) external; // governor
Rate recording resolves per node: rateRecorderOf[node] when bound, the global rateRecorder otherwise; both propose-time escalations and finalized actions are charged against the window.
Session gating: propose requires a valid SessionRegistry key for the agent, with value <= maxValue and, when pinned, target == allowedTarget. Events: IntentCreated, IntentEscalated, IntentResolved, ActionExecuted(agent, target, value, callOk).
Quorum voting over structure, budgets, and policy upgrades. Two tiers:
Tier.Low): instant execution once yesVotes >= quorumYes.Tier.High): treasury/policy-touching; additionally requires yesHumanVotes >= quorumHumanYes, a timelock (eta), and remains human-vetoable until execution.Seats are role-weighted (SeatRole.Human | Agent); agent seats carry review authority but human seats hold final say on high tier. Any funded Human seat may veto.
function propose(Tier tier, address target, bytes calldata data) external returns (uint256); function vote(uint256 proposalId, bool support) external; function veto(uint256 proposalId) external; // any funded human seat function execute(uint256 proposalId) external; // after quorum (+ timelock on high) function setVotingPower(address voter, uint256 power, SeatRole role) external; // root: agent seats function admitHuman(address human, uint256 power) external; // governance only function removeHuman(address human) external; // governance only function humanSeatCount() external view returns (uint256);
Events: ProposalCreated, Voted, ProposalExecuted, ProposalVetoed, ProposalDefeated, VotingPowerUpdated, HumanAdmitted, HumanRemoved.
The seat roster is itself constitutional. Changing it splits by seat class:
setVotingPower(voter, power, Agent), callable by the root address directly. Agent weight can never satisfy high tier, so handing it out cannot hand out final say.admitHuman / removeHuman, and any setVotingPower that creates, re-weights, or revokes a Human seat. These accept the module itself as caller and nobody else, so they run only as an executed proposal; and because propose forces High tier on anything targeting the module, that proposal is always high tier. Admitting a partner therefore passes the humans already seated, any one of whom can veto it.Two guarantees hold unconditionally:
LastHumanSeat). Not by removeHuman, not by demoting the seat to an agent one. An org with no human seat has handed high-tier final say to nobody at all — agent yes-weight never satisfies it — which freezes the constitution rather than passing it on.The one carve-out: while humanSeatCount == 0, the root may seat a human directly. That state exists only for a module deployed with rootPower_ = 0, which would otherwise be born ungovernable; the carve-out closes the moment the first human is seated.
The root's direct authority — quorums, timing, agent seats, and its veto — is the privilege of a seated human, not of an address: it holds while the root holds a funded Human seat (or while nobody does). Governance that revokes the root's seat revokes its parameter admin and its veto with it. “Root” is a seat that can change hands, not a permanent key.
Observer seats are not modelled. A seat with power 0 is a revoked seat — setVotingPower coerces role None at zero weight, and vote() reverts NoVotingPower. A human who should watch without voting is an off-chain concern (workspace membership), not a chain-level seat, because the veto right this contract grants is derived from funded human seats and a zero-weight veto-holder would be a contradiction.
Session issuer / Safe ownership stays single-holder in v1. A second human gets a governance seat and a veto; the session-issuer and treasury-wallet paths still key off one root address. A club that wants two humans to jointly own the wallet configures a 2-of-2 Safe at the wallet layer — the protocol does not yet model shared root custody, and pretending otherwise in the tree would be the dishonest version of this feature. See SECURITY.md for what that leaves exposed.
Agents boot with ephemeral keys scoped to their policy; orchestrator compromise leaks bounded, expiring authority — never the treasury.
function issue(address agent, address key, uint64 expiresAt, bytes32 scopesHash, uint256 maxValue, address allowedTarget) external returns (uint256); // issuer function issueScoped(address agent, address key, uint64 expiresAt, bytes32 scopesHash, uint256 maxValue, address[] calldata allowedTargets) external returns (uint256); function revoke(uint256 sessionId) external; // root or issuer function isKeyValid(address agent, address key) external view returns (bool); function isTargetAllowed(address agent, address key, address target) external view returns (bool); function allowedTargetsOf(uint256 sessionId) external view returns (address[] memory); function keyLimits(address agent, address key) external view returns (bool, uint256, address, bytes32);
Target scoping. A session pins zero or more targets: empty means any target that still passes the node's policy stack; one or more restrict the key to exactly those. Enforcement uses isTargetAllowed. keyLimits reports only the first pinned target, never address(0), so a consumer that checks just keyLimits denies the extra targets instead of allowing everything — fail-closed by construction.
Events: SessionIssued, SessionTargetsPinned (when >1 target), SessionRevoked. Root revocation never depends on the issuer — the root key can always kill a session.
Consumers index these families; the reference indexer streams them into Postgres (orchestrator_audit_events), which dashboards and monitors read.
| Family | Events | What it answers |
|---|---|---|
| Intents | IntentCreated IntentEscalated IntentResolved ActionExecuted | Every proposed action, how it climbed, and how it settled |
| Payroll | GrantUpdated EpochRun | Budget changes and every streamed epoch |
| Governance | ProposalCreated Voted ProposalExecuted ProposalVetoed ProposalDefeated | Constitutional decisions end to end, vetoes included |
| Sessions | SessionIssued SessionRevoked | Every grant and every kill of agent authority |
| Structure | NodeAdded NodeRemoved NodeReparented NodeActiveUpdated | Org-chart changes: hires, removals, reporting moves |
An implementation conforms to LaCrew v0.1 if:
IPolicyModule.check stack with first-DENY-wins / any-ESCALATE-climbs semantics before funds move;OrgRegistry tree, terminating at a human root;expiresAt, maxValue, and allowedTarget.Security process: SECURITY.md. Threat notes: the protocol security doc.