LaCrew Protocol Specification
The normative interface surface, invariants, and conformance rules.
Version: 0.1.1 (draft) · License: Apache-2.0 · Solidity: ^0.8.28
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 document is the normative protocol surface. Narrative documentation
lives in overview docs;
reference implementations in contracts/src/. Versions
follow semver: breaking interface changes bump the minor pre-1.0.
1. Design invariants
- Non-custodial. No off-chain component ever holds root keys or an unmediated treasury path. Orchestrators act through scoped, expiring session keys; revocation runs from the root key.
- All enforcement onchain. Budgets, permissions, escalation, and governance are contract-enforced. The cloud is replaceable.
- First DENY wins. A node's policy is a stack of modules; DENY short-circuits, any ESCALATE routes the action up the reporting tree.
- Audit trail by construction. Every intent, verdict, approval, vote, stream, and session event is emitted onchain; no separate logging system.
- Composability. Third parties extend by writing policy modules and adapters — never by forking the protocol.
2. IPolicyModule — the extension point
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. Standard modules shipped as
reference:
| 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 (rateRecorderOf), 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.
2.1 Third-party modules — writing one, listing one
IPolicyModule is a public extension point: a guardrail nobody here wrote is
bound the same way a shipped one is. To publish one:
- Implement
checkas aviewfunction. It is called insideEscalationRouter.proposeand inside everyPolicyStackit 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. - Deploy it and keep the address. A listing points at a deployed module; the marketplace never compiles or deploys a seller's bytecode, so nothing a third party publishes ever runs off-chain.
- Publish a
policyModulelisting whose payload is validated byvalidatePolicyModulePayload(@lacrew/flows):id,version,name,summary, thedeployments(one{chainId, address}per chain you support), theslotsthe module is written for, and anauditclaim. Absent audit metadata meansunaudited, which is what the catalog labels it. A listing may instead name astandardModule— one of the modules a deployment already carries in its address book — which is how the 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
(POST /governance/attach-policy-module) 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.
3. OrgRegistry — the tree
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.
3.1 Multi-human orgs — one tree, several humans
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.
4. Treasury & EpochStreamer — payroll semantics
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.
4.1 Multi-asset orgs
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. Proven in contracts/test/MultiAsset.t.sol: 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.
SpendCapPolicycompares rawuint256values, 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.
5. EscalationRouter — the enforcement path
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; // governorRate recording resolves per node: rateRecorderOf[node] when bound, the
global rateRecorder otherwise; both propose-time escalations and
finalized actions are charged against the window.
- ALLOW → the action finalizes immediately: allowance spent,
targetcalled,ActionExecutedemitted. - ESCALATE → a pending intent is created awaiting the agent's parent.
resolve(id, true)from the awaiting approver re-checks the approver's own policy stack: within bounds it finalizes; over bounds the intent climbs (IntentEscalated) toward the human root.resolve(id, false)closes it. - DENY →
proposereverts; nothing is created.
Session gating: propose requires a valid SessionRegistry key for
agent, with value <= maxValue and, when pinned, target == allowedTarget.
Events: IntentCreated, IntentEscalated, IntentResolved,
ActionExecuted(agent, target, value, callOk).
6. GovernanceModule — constitutional actions
Quorum voting over structure, budgets, and policy upgrades. Two tiers:
- Low (
Tier.Low): instant execution onceyesVotes >= quorumYes. - High (
Tier.High): treasury/policy-touching; additionally requiresyesHumanVotes >= 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.
6.1 Seat admin — who may change who holds final say
The seat roster is itself constitutional. Changing it splits by seat class:
- Agent seats —
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. - Human seats —
admitHuman/removeHuman, and anysetVotingPowerthat creates, re-weights, or revokes aHumanseat. These accept the module itself as caller and nobody else, so they run only as an executed proposal; and becauseproposeforces 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:
- The last human seat cannot be revoked (
LastHumanSeat). Not byremoveHuman, 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. - Agents are never the sole final say. This is the same guarantee read from the other end, and it is why (1) is a revert rather than a warning.
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.
7. SessionRegistry — bounded, expiring authority
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.
8. Event taxonomy (audit trail)
Consumers index these families; the reference indexer streams them into
Postgres (orchestrator_audit_events), which dashboards and monitors read.
| Family | Events |
|---|---|
| Intents | IntentCreated, IntentEscalated, IntentResolved, ActionExecuted |
| Payroll | GrantUpdated, EpochRun (surfaced as AllowanceStreamed) |
| Governance | ProposalCreated, Voted, ProposalExecuted, ProposalVetoed, ProposalDefeated |
| Sessions | SessionIssued, SessionRevoked |
| Structure | NodeAdded, NodeRemoved, NodeReparented, NodeActiveUpdated |
9. Conformance
An implementation conforms to LaCrew v0.1 if:
- every agent action passes an
IPolicyModule.checkstack with first-DENY-wins / any-ESCALATE-climbs semantics before funds move; - escalations resolve only through ancestors in the
OrgRegistrytree, terminating at a human root; - treasury value reaches agents only as streamed allowances, spent through the router's finalize path;
- constitutional actions execute only through the governance tiers above, with high-tier human final say and veto;
- off-chain actors sign with registry-issued session keys bounded by
expiresAt,maxValue, andallowedTarget.
Security process: see SECURITY.md. Threat notes:
security docs.