bitcd

The contract

One Cairo contract, Bitcd, holds the keys, their versions, leases and policies. It decides whether each write is allowed and announces every change as an event. It does not interpret what a value means; the tools around it do.

Source: bitcd-core/contracts, a Scarb package named bitcd (Cairo 2.19, OpenZeppelin Ownable). Each organization deploys its own copy.

ModuleHolds
lib.cairoThe shared record types, the action/condition/scope constants, the IBitcd interface, and the #[starknet::contract] itself.
policy.cairosatisfies(policy, approvers) — checks a set of approvals against a policy's limits. No scripting, just a few fixed checks.
governance.cairoFixed-arity decoders for proposal parameters, one per action.
locks.cairoLease expiry: pure functions over block timestamps.
snip12.cairoThe SNIP-12 typed-data hash a human signs to approve a proposal.
events.cairoThe event payloads — the contract's watch stream.

Data model

Every name in the contract is one felt252: keys, prefixes, roles, schema references. The off-chain tools write them as Cairo short strings, up to 31 ASCII characters. str() in @bitcd/core/protocol refuses a longer one. Hash longer names first.

Keys are grouped into prefixes such as k8s/rbac or app/flags. Each prefix has its own policies, one per action. A lock or value is bound to its prefix on first write and cannot move to one with weaker rules ('acquire: prefix rebind', 'value: prefix rebind'). Prefix 0 is unbound and unusable. The prefix 'GLOBAL' is reserved for signer management.

RecordFieldsMeaning
ValueRecorddigest, schema_ref, storage_class, version, prefix, exists, tombstonedDesired state. The content digest of an off-chain blob, what schema it follows, and where it is stored — an uninterpreted hint such as 'S3' or 'OCI', or 0 for no external blob. The bytes never go on-chain. version is monotonic and every write is compare-and-set against it.
StatusRecordobserved_revision, observed_digest, condition, reason, attestation, status_version, executor, existsObserved state, as agreed by the executors: which desired version they reconciled, the digest of what they saw live, and the outcome. Its version counts up separately from the value's.
AttestationRecordobserved_revision, applied_hash, condition, seenOne executor's own report for one key. The StatusRecord is promoted from these.
LockRecordholder, acquired_at, ttl, operation_id, version, tombstonedA lease. ttl == 0 never expires; otherwise it expires when now − acquired_at ≥ ttl, in block time.

Two smaller types describe who may write:

struct Policy {
    threshold: u32,         // eligible approvers needed
    role: felt252,          // required signer role; 0 = any role
    min_humans: u32,        // how many of them must be intent-bound humans
    max_per_operator: u32,  // cap per operator_id; 0 = no cap
    allow_open: bool,       // the ungoverned fast path (ACQUIRE, open SET_VALUE)
    exists: bool,
}

struct Signer {
    active: bool,
    is_human: bool,
    operator_id: felt252,   // who runs this key — the diversity dimension
    role: felt252,
}

History is only added to. A delete marks the value tombstoned and bumps its version; a later write to the same key revives it and the version keeps counting. Lease timeouts use the block timestamp.

Actions and policy

There are seven kinds of write. Each prefix has a policy for each kind it allows. No policy means the action is impossible on that prefix. The other entry points, such as releasing a lease or approving a proposal, have their own checks, listed under the interface.

ActionKindEntry pointWhat gates it
ACQUIREopenacquire_lockThe policy must exist and be allow_open. First come, first served.
SET_VALUEdual-modeset_value · commitOpen: the caller holds a live lease on a lock in the same prefix. Governed: a quorum through commit. A prefix is one or the other, never both.
TOMBSTONE_VALUEgovernancecommitA quorum. On an open prefix, the lock holder can also call tombstone_value, gated by the open SET_VALUE policy.
SET_STATUSattestationset_statusThe caller is an active signer whose role matches the policy. Promotion to the aggregate status needs the policy's quorum of agreeing executors.
FORCE_UNLOCKgovernancecommitA quorum over the lock's own prefix.
SET_POLICYgovernancecommitA quorum over the prefix whose policy changes. Strengthen-only for governance gates.
SET_ROLEgovernancecommitA quorum over 'GLOBAL' only — signer identity is global.

How a policy is evaluated

At commit, satisfies(policy, approvers) counts who approved. An approver counts if they are an active signer with the role the policy names; role 0 accepts any role. The approval passes when:

  • at least one approver counts, and at least threshold of them do;
  • at least min_humans of them are people. A person counts only when they approved by signing (see human approvals). Executor policies usually set this to 0;
  • no single operator has more than max_per_operator counted approvers (0 means no cap). If one operator exceeds the cap, the whole approval fails, even if the rest would be enough.

Signers are read at commit time. Deactivating a signer removes their approvals from open proposals.

Rules the contract enforces regardless of policy

  • Governance needs a quorum. The policies for FORCE_UNLOCK, SET_POLICY, SET_ROLE and TOMBSTONE_VALUE cannot be open and need at least one approver, at setup and after.
  • Governance only gets stricter. A quorum changing a governance policy can raise threshold or min_humans, not lower them. A finite max_per_operator can shrink, not grow or become unlimited. The role cannot change and the policy cannot be removed.
  • A quorum stays in its namespace. A proposal can only touch the prefix it was approved under.
  • Prefixes are fixed at setup. Adding or changing a policy on a prefix requires a SET_POLICY policy on that prefix. Reserve spare prefixes before renouncing.

IBitcd

Views are free off chain. Everything else is a transaction.

FunctionCallerDoes
Leases
acquire_lock(lock_key, prefix_hash, operation_id, ttl)anyoneTakes a free, released or expired lock; pins the lock to prefix_hash on first use. Emits LockAcquired.
release_lock(lock_key, expected_version)holderReleases with compare-and-set on the lock version. Emits LockReleased.
get_lock(lock_key) → LockRecordview
Values — open prefixes
set_value(value_key, lock_key, digest, schema_ref, storage_class, expected_version)lock holderCommits a digest; the live lease is the write capability. Emits ValueChanged.
tombstone_value(value_key, lock_key, expected_version)lock holderLogical delete. Emits ValueChanged.
get_value(value_key) → ValueRecordview
Observed state
set_status(value_key, observed_revision, applied_hash, condition, reason)executor roleRecords this executor's attestation; promotes the aggregate when the quorum agrees. Emits StatusAttested, and StatusChanged on promotion.
get_status(value_key) → StatusRecordview
get_attestation(value_key, executor) → AttestationRecordview
Governance
propose(action, prefix_hash, params) → u64active signerStores an immutable proposal and returns its id. Emits ProposalCreated.
approve(proposal_id)active signerA machine approval: counts toward threshold, never toward min_humans. Emits ProposalApproved.
approve_sigs(proposal_id, approvals)anyone (relayer)Submits up to 64 SNIP-12 signed approvals; each is verified by its own account. All-or-nothing. Emits ApprovalSigned.
approval_digest(proposal_id, approver) → felt252viewThe exact hash a human must sign.
request_human_review(proposal_id)eligible signerRaises this proposal's human floor to at least one. Cannot be undone. Emits HumanReviewRequested.
commit(proposal_id)anyoneEvaluates the policy and, if satisfied, performs the action. Emits ProposalCommitted.
Setup — owner only, until renounced
set_policy(prefix_hash, action, policy)ownerSeeds a policy, subject to the governance floor. Emits PolicyChanged.
set_signer(account, signer)ownerSeeds a signer. Emits RoleChanged.
get_policy(prefix_hash, action) · get_signer(account)view
transfer_ownership · renounce_ownershipownerOpenZeppelin Ownable. Renouncing ends the owner path permanently.
owner() → ContractAddressviewZero once renounced.

How a proposal commits

A governed change is propose, then approve, then commit. The action, prefix and parameters are fixed when the proposal is created. The policy and signer records are read at commit. A force-unlock approval names the lock, not a particular lease. Anyone can call commit. Proposals do not expire.

// what the contract does on commit(id)
policy = policies[(prop.prefix, prop.action)]           // must exist
if human_review_requested(id): policy.min_humans = max(policy.min_humans, 1)
assert satisfies(policy, approvers(id))                  // 'commit: policy unmet'
mark executed                                            // before dispatch
dispatch(prop.action, prop.params)                       // scope-checked per action

Parameters are a flat list of felt252s with a fixed count per action. A malformed proposal can be created; it fails at commit.

ActionparamsScope check at commit
SET_VALUE[value_key, digest, schema_ref, storage_class, expected_version]The prefix's SET_VALUE policy is governed; the value is bound to this prefix (pinned here on first write).
TOMBSTONE_VALUE[value_key, expected_version]The value exists and belongs to this prefix.
SET_POLICY[target_prefix, target_action, threshold, role, min_humans, max_per_operator, allow_open, exists]target_prefix equals the proposal's prefix; the ratchet applies to governance gates.
SET_ROLE[account, active, is_human, operator_id, role]The proposal's prefix is 'GLOBAL'.
FORCE_UNLOCK[lock_key]The lock belongs to this prefix and is held.

Any signer eligible under the proposal’s policy can call request_human_review. The proposal then needs at least one signed human approval, however many machines approve it. The request cannot be withdrawn. It is checked at commit rather than counted as a vote, so a majority cannot outvote it.

Human approvals

People approve by signing a message, not by sending a transaction. The signatures reach the contract through approve_sigs, and only those count toward min_humans. A person who calls plain approve is counted as a machine. The message follows SNIP-12:

StarknetDomain { name: 'bitcd', version: 1, chainId, revision: 1 }
Approval {
    verifyingContract,   // this contract — no cross-contract replay
    proposalId,          // ids are monotonic — no cross-proposal replay
    action,              // e.g. 'SET_VALUE'
    prefix,              // e.g. 'k8s/rbac'
    paramsHash,          // Poseidon('bitcd:params:v1', len, ...params)
}
// hashed with the approver's address, per SNIP-12 — not transferable

The relayer that submits the signatures is not trusted. The contract rebuilds the message from the stored proposal and asks the approver’s own account whether the signature is valid (SRC6 is_valid_signature must return 'VALID'). A relayer can delay a signature. It cannot alter one or attribute it to someone else. Any account that accepts a SNIP-12 revision 1 signature works, including hardware wallets and multisig accounts.

The action, prefix and proposal id are readable in the message. The parameters appear as a hash. A signing screen should decode the stored parameters, fetch and check the document they point to, and show both before asking for a signature.

approvalTypedData, signApproval and approveWithSigs in @bitcd/core implement this. assertRoundTrip compares the result with the contract’s approval_digest before anything is sent.

Executor reports

Executors report with set_status. The call writes status only; it has no path to values, policies or signers, and nothing on the approval side writes status. A report, true or false, cannot change what was decided. This holds only while executor accounts stay out of the quorums that approve changes. The bitcd.yaml lints enforce that; the contract alone does not. On each report:

  1. The contract checks that the value exists and that the caller is an active signer with the executor role named in the prefix’s SET_STATUS policy, which cannot be open.
  2. It stores the report in that executor’s own slot: the version reconciled, the digest observed, the outcome. An executor cannot report a version that does not exist yet, or go backwards.
  3. It gathers the executors whose latest report matches this one and runs the same policy check used for proposals. If enough agree, the shared StatusRecord is updated.
  4. If not enough agree and another report differs for the same version, the shared status becomes DISAGREE. Only the contract sets DISAGREE. The shared status never moves back to an older version.

A single executor is threshold 1. Give each kind of executor its own role, EXEC_K8S, EXEC_IAM, rather than one shared EXECUTOR. Roles are global, so a shared role could report on every namespace.

The contract does not compare what executors saw with what was wanted. Readers do. The reference reader reports DISAGREE first; then SYNCED when the agreed status is at the current version with the desired digest, FAILED when the executors agreed the apply failed, and OUTOFSYNC otherwise, including a value never reconciled.

Events

Every change is an event. The key fields are indexed, so a reader can follow one value, executor or proposal.

EventKeysData
ValueChangedvalue_key, prefixdigest, schema_ref, storage_class, version
StatusAttestedvalue_key, executorobserved_revision, applied_hash, condition
StatusChangedvalue_key, prefix, executorobserved_revision, observed_digest, condition, status_version
ProposalCreatedproposal_id, proposeraction, prefix_hash
ProposalApprovedproposal_id, approver
ApprovalSignedproposal_id, approverMarks an intent-bound approval. A fresh one also emits ProposalApproved; upgrading an earlier raw approval emits only this.
HumanReviewRequestedproposal_id, requester
ProposalCommittedproposal_idaction
PolicyChangedprefix_hash, actionthreshold, role
RoleChangedaccountactive, role
LockAcquiredlock_key, holderoperation_id, version, acquired_at, ttl
LockReleasedlock_key, holderversion
LockForceUnlockedlock_key, previous_holderversion

The reference indexer in bitcd-core/indexer and the folds in @bitcd/core/events turn the stream into a read model: sync state per key, executor reports per revision, raw versus signed approvals per proposal. When a view and the contract disagree, the contract counts.

Revert reasons

A refusal carries a short string. The common ones:

ReasonMeaning
commit: policy unmetThe approvals do not satisfy the policy — too few, too few intent-bound humans, wrong roles, or one operator over its cap.
commit: no policyNothing is seeded at (prefix, action), so the action is impossible on that prefix.
commit: weakens threshold · weakens min_humans · weakens diversity · changes gov role · cannot clear govThe ratchet refused a SET_POLICY that would loosen a governance gate.
commit: … out of scope · role not globalThe proposal's prefix is not where its target lives.
set_value: stale version · value: stale version · release: stale versionCompare-and-set lost: someone wrote first. Re-read and retry.
value: governed · commit: value is openWrong path for the prefix's SET_VALUE mode.
value: lease expired · value: not holder · acquire: heldLease problems on the open path.
acquire: prefix rebind · value: prefix rebindThe key is already bound to a different prefix.
status: wrong role · status: future revision · status: revision regressAn attestation from the wrong executor domain, for a version that does not exist yet, or older than this executor's last report.
approve_sigs: bad signature · approve_sigs: duplicateThe account rejected a signature, or this human already signed. The whole batch reverts.
policy: gov not open · policy: gov needs quorumThe governance floor refused an open or zero-threshold governance policy.

Deploy

A temporary owner deploys the contract, seeds signers and policies, checks them and renounces. After that, policy and signer changes need the quorums you seeded, and each value follows its prefix’s mode: a quorum, or on an open prefix, a live lease.

1. Build and test

$ cd bitcd-core/contracts
$ scarb build          # Scarb 2.19 / Cairo 2.19
$ snforge test         # Starknet Foundry

Usually you do not build it yourself. @bitcd/core embeds the compiled Sierra and CASM, and core/artifacts/MANIFEST.json pins the class hash to the commit it was built from. CI rebuilds the contract and fails if the embedded class hash drifts.

2a. From a bitcd.yaml

Describe the organization in one file and let the bitcd CLI compile it into a checked set of policies:

$ bitcd validate bitcd.yaml          # schema + security lints
$ bitcd plan bitcd.yaml              # the signers and policies it will seed
$ bitcd apply-ceremony bitcd.yaml --rpc $BITCD_RPC --keys keys.json   # deploy, seed, renounce
$ bitcd publish-manifest bitcd.yaml --keys keys.json   # the governed definition, through its quorum
$ bitcd diff bitcd.yaml              # yaml ↔ ownership, policies, signers, manifest

The keystore is JSON, {name: {address, pk}}. The funded transient owner is the entry named genesis, or the one passed with --owner. The deployment record goes to --deployment (default ./deployment.json).

apply-ceremony is idempotent: a crashed run resumes, skips entries already correct and stops on one that differs. It refuses an instance that is already renounced. It does not read the seed back before renouncing. On a public network run it first with ceremony.renounce: false, check with bitcd diff, then set renounce to true and run again. Until publish-manifest has run, diff reports the manifest as missing. The compiler emits the policies each declared surface needs. A surface can be amended later only where the file marks it mutable; the global, manifest and fleet gates are always included. See bitcd.yaml.

2b. From code

The same ceremony as library calls. Addresses and keys come from your configuration; @bitcd/core comes from npm (npm install @bitcd/core starknet), or from a side-by-side clone ("@bitcd/core": "link:../bitcd-core/core") for unreleased work.

import { provider, account, bitcdContract, str } from "@bitcd/core/client";
import { ACTION, GLOBAL } from "@bitcd/core/protocol";
import { deployBitcd, runCeremony, signerSpec, policySpec } from "@bitcd/core/ceremony";

const p = provider();                                  // BITCD_RPC
const owner = account({ address: OWNER, pk: OWNER_PK }, p);
const { address } = await deployBitcd({ owner, deploymentFile: "deployment.json" });

const OPS = str("OPERATOR"), FLAGS = str("app/flags");
// policySpec(threshold, role, min_humans, max_per_operator, allow_open)
const quorum = policySpec(2, OPS, 1, 1, false);        // 2 operators, 1 human, 1 per operator

await runCeremony({
  contract: bitcdContract(address, owner), provider: p,
  signers: [
    { address: ALICE, signer: signerSpec({ is_human: true,  operator_id: str("OP_A"), role: OPS }) },
    { address: BOB,   signer: signerSpec({ is_human: true,  operator_id: str("OP_B"), role: OPS }) },
    { address: AGENT, signer: signerSpec({ is_human: false, operator_id: str("OP_C"), role: OPS }) },
  ],
  policies: [
    { prefix: GLOBAL, action: ACTION.SET_ROLE,        policy: quorum },
    { prefix: GLOBAL, action: ACTION.SET_POLICY,      policy: quorum },
    { prefix: FLAGS,  action: ACTION.SET_VALUE,       policy: quorum },
    { prefix: FLAGS,  action: ACTION.TOMBSTONE_VALUE, policy: quorum },
    { prefix: FLAGS,  action: ACTION.SET_POLICY,      policy: quorum },
    // + (FLAGS, SET_STATUS) for your executor role
  ],
  renounce: false,   // read the seed back with get_policy / get_signer first
});

Read every policy and signer back. When they are right, call renounce_ownership as a separate step.

Renouncing cannot be undone. On a public network a missing or wrong policy can afterwards be fixed only by a quorum the setup allows, or by a fresh deployment. Rehearse on devnet, then on Sepolia, before mainnet. The commands are the same on each network; fund its accounts and keep a separate deployment record. Onboarding lists what changes on a public network.

Before renouncing

  • ('GLOBAL', SET_ROLE) and ('GLOBAL', SET_POLICY) exist, so signers can be rotated later.
  • Every prefix whose policies may change later has SET_POLICY, alongside SET_VALUE, TOMBSTONE_VALUE and SET_STATUS as needed. Spare prefixes are reserved.
  • Human signers are held by different people, and operator_id reflects who controls each key.
  • Each quorum can still be met if someone loses a key.
  • Every executor account has funds. It pays for its own reports.

Reuse

The contract does not know what a RoleBinding, an IAM policy or a tool allowlist is. A new use case is a prefix, a schema for its documents and an off-chain controller that reads, checks and applies them. One instance hosts many side by side.

  • Keyspace. One prefix per area of risk: k8s/rbac, aws/iam, app/flags. Each gets its own quorum, human floor and executor role. Keys fit in 31 characters.
  • Value mode. Governed SET_VALUE for anything that grants access. Open mode, where the lease holder writes, is for single-writer state such as a lease-guarded pointer, and only on one prefix: a hijacked open value cannot be recovered.
  • Storage class. The chain stores {digest, schema_ref, storage_class}. The document lives wherever the class points. Readers re-hash what they fetch.
  • Accounts. A signer is a Starknet account address: a team multisig, a hardware wallet, an agent’s own account. Each carries an operator_id.

Reading from another contract

A contract can refuse to act unless a key is in sync at its latest version. Add bitcd as a path dependency from a side-by-side clone and use the generated dispatcher:

# Scarb.toml
[dependencies]
bitcd = { path = "../bitcd-core/contracts" }
use bitcd::{IBitcdDispatcher, IBitcdDispatcherTrait};
use bitcd::conditions::SYNCED;

let bitcd = IBitcdDispatcher { contract_address: bitcd_address };
let v = bitcd.get_value('app/flags/checkout');
let s = bitcd.get_status('app/flags/checkout');
assert(v.exists && !v.tombstoned, 'no value');
assert(v.prefix == 'app/flags', 'wrong namespace');  // a key's name alone doesn't prove its prefix
assert(s.observed_revision == v.version && s.condition == SYNCED, 'not synced');
assert(s.observed_digest == v.digest, 'drift');

Extend

Most additions need no contract change: a new schema, connector, voter or reader lives outside it. Build on it has recipes. Wanting the contract to understand a value is a sign the logic belongs in whatever proposes or applies it.

Left out on purpose: a scripting language for policies (a new option becomes a new field), business logic on chain, reading the chain on every request, and any way to erase history.

Changing the contract

The contract cannot be upgraded in place. A change means a new class hash and a new deployment with its own ceremony. To add an action:

  1. Add the tag to actions in lib.cairo and decide whether it belongs in is_governance_action. Governance actions get the floor and the ratchet; fast paths stay out.
  2. Add a fixed-arity decoder to governance.cairo.
  3. Add a branch to commit that asserts the target lives in the proposal’s prefix before touching storage.
  4. Emit an event for the state change.
  5. Add snforge tests, including the attacks: a weaker prefix reaching in, a wrong arity, a replayed approval.
  6. Run pnpm sync-artifacts so @bitcd/core embeds the new class, and add the tag to ACTION in @bitcd/core/protocol.

A change to governance, policy checks or commit must keep every property in bitcd-core/AGENTS.md: namespaces stay separate, governance keeps its quorum and only gets stricter, keys stay bound to their prefix, status stays apart from decisions, human approvals stay signed, escalation only adds reviewers. snip12.cairo stays byte-for-byte compatible with core/src/snip12.mjs.