LaCrew docs

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

  1. 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.
  2. All enforcement onchain. Budgets, permissions, escalation, and governance are contract-enforced. The cloud is replaceable.
  3. First DENY wins. A node's policy is a stack of modules; DENY short-circuits, any ESCALATE routes the action up the reporting tree.
  4. Audit trail by construction. Every intent, verdict, approval, vote, stream, and session event is emitted onchain; no separate logging system.
  5. 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:

ModuleBehavior
SpendCapPolicyPer-agent cap on value; over-cap → ESCALATE. Mutator setAgentCap is admin- or governor-gated.
WhitelistPolicyUnlisted target → DENY. Mutator setAllowed is admin- or governor-gated.
RateLimitPolicySliding-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.
TimeWindowPolicyOutside 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:

  1. Implement 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.
  2. 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.
  3. Publish a policyModule listing whose payload is validated by validatePolicyModulePayload (@lacrew/flows): id, version, name, summary, the deployments (one {chainId, address} per chain you support), 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 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 livesWhat it answers
The chartOrgRegistry node of kind HumanRootWho is drawn as a human, and who reports to them
The authorityGovernanceModule seat with SeatRole.HumanWho 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. 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.

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; // 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.

  • ALLOW → the action finalizes immediately: allowance spent, target called, ActionExecuted emitted.
  • 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.
  • DENYpropose reverts; 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 once yesVotes >= quorumYes.
  • High (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.

6.1 Seat admin — who may change who holds final say

The seat roster is itself constitutional. Changing it splits by seat class:

  • Agent seatssetVotingPower(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 seatsadmitHuman / 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:

  1. The last human seat cannot be revoked (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.
  2. 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.

FamilyEvents
IntentsIntentCreated, IntentEscalated, IntentResolved, ActionExecuted
PayrollGrantUpdated, EpochRun (surfaced as AllowanceStreamed)
GovernanceProposalCreated, Voted, ProposalExecuted, ProposalVetoed, ProposalDefeated
SessionsSessionIssued, SessionRevoked
StructureNodeAdded, NodeRemoved, NodeReparented, NodeActiveUpdated

9. Conformance

An implementation conforms to LaCrew v0.1 if:

  1. every agent action passes an IPolicyModule.check stack with first-DENY-wins / any-ESCALATE-climbs semantics before funds move;
  2. escalations resolve only through ancestors in the OrgRegistry tree, terminating at a human root;
  3. treasury value reaches agents only as streamed allowances, spent through the router's finalize path;
  4. constitutional actions execute only through the governance tiers above, with high-tier human final say and veto;
  5. off-chain actors sign with registry-issued session keys bounded by expiresAt, maxValue, and allowedTarget.

Security process: see SECURITY.md. Threat notes: security docs.

On this page