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.
| Module | Holds |
|---|---|
| lib.cairo | The shared record types, the action/condition/scope constants, the IBitcd interface, and the #[starknet::contract] itself. |
| policy.cairo | satisfies(policy, approvers) — checks a set of approvals against a policy's limits. No scripting, just a few fixed checks. |
| governance.cairo | Fixed-arity decoders for proposal parameters, one per action. |
| locks.cairo | Lease expiry: pure functions over block timestamps. |
| snip12.cairo | The SNIP-12 typed-data hash a human signs to approve a proposal. |
| events.cairo | The 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.
| Record | Fields | Meaning |
|---|---|---|
| ValueRecord | digest, schema_ref, storage_class, version, prefix, exists, tombstoned | Desired 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. |
| StatusRecord | observed_revision, observed_digest, condition, reason, attestation, status_version, executor, exists | Observed 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. |
| AttestationRecord | observed_revision, applied_hash, condition, seen | One executor's own report for one key. The StatusRecord is promoted from these. |
| LockRecord | holder, acquired_at, ttl, operation_id, version, tombstoned | A 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.
| Action | Kind | Entry point | What gates it |
|---|---|---|---|
| ACQUIRE | open | acquire_lock | The policy must exist and be allow_open. First come, first served. |
| SET_VALUE | dual-mode | set_value · commit | Open: 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_VALUE | governance | commit | A quorum. On an open prefix, the lock holder can also call tombstone_value, gated by the open SET_VALUE policy. |
| SET_STATUS | attestation | set_status | The caller is an active signer whose role matches the policy. Promotion to the aggregate status needs the policy's quorum of agreeing executors. |
| FORCE_UNLOCK | governance | commit | A quorum over the lock's own prefix. |
| SET_POLICY | governance | commit | A quorum over the prefix whose policy changes. Strengthen-only for governance gates. |
| SET_ROLE | governance | commit | A 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
thresholdof them do; - at least
min_humansof them are people. A person counts only when they approved by signing (see human approvals). Executor policies usually set this to0; - no single operator has more than
max_per_operatorcounted approvers (0means 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_ROLEandTOMBSTONE_VALUEcannot be open and need at least one approver, at setup and after. - Governance only gets stricter. A quorum changing a governance policy can raise
thresholdormin_humans, not lower them. A finitemax_per_operatorcan 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_POLICYpolicy on that prefix. Reserve spare prefixes before renouncing.
IBitcd
Views are free off chain. Everything else is a transaction.
| Function | Caller | Does |
|---|---|---|
| Leases | ||
| acquire_lock(lock_key, prefix_hash, operation_id, ttl) | anyone | Takes a free, released or expired lock; pins the lock to prefix_hash on first use. Emits LockAcquired. |
| release_lock(lock_key, expected_version) | holder | Releases with compare-and-set on the lock version. Emits LockReleased. |
| get_lock(lock_key) → LockRecord | view | |
| Values — open prefixes | ||
| set_value(value_key, lock_key, digest, schema_ref, storage_class, expected_version) | lock holder | Commits a digest; the live lease is the write capability. Emits ValueChanged. |
| tombstone_value(value_key, lock_key, expected_version) | lock holder | Logical delete. Emits ValueChanged. |
| get_value(value_key) → ValueRecord | view | |
| Observed state | ||
| set_status(value_key, observed_revision, applied_hash, condition, reason) | executor role | Records this executor's attestation; promotes the aggregate when the quorum agrees. Emits StatusAttested, and StatusChanged on promotion. |
| get_status(value_key) → StatusRecord | view | |
| get_attestation(value_key, executor) → AttestationRecord | view | |
| Governance | ||
| propose(action, prefix_hash, params) → u64 | active signer | Stores an immutable proposal and returns its id. Emits ProposalCreated. |
| approve(proposal_id) | active signer | A 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) → felt252 | view | The exact hash a human must sign. |
| request_human_review(proposal_id) | eligible signer | Raises this proposal's human floor to at least one. Cannot be undone. Emits HumanReviewRequested. |
| commit(proposal_id) | anyone | Evaluates the policy and, if satisfied, performs the action. Emits ProposalCommitted. |
| Setup — owner only, until renounced | ||
| set_policy(prefix_hash, action, policy) | owner | Seeds a policy, subject to the governance floor. Emits PolicyChanged. |
| set_signer(account, signer) | owner | Seeds a signer. Emits RoleChanged. |
| get_policy(prefix_hash, action) · get_signer(account) | view | |
| transfer_ownership · renounce_ownership | owner | OpenZeppelin Ownable. Renouncing ends the owner path permanently. |
| owner() → ContractAddress | view | Zero 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 actionParameters are a flat list of felt252s with a fixed count per action. A malformed proposal can be created; it fails at commit.
| Action | params | Scope 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 transferableThe 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:
- 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_STATUSpolicy, which cannot be open. - 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.
- It gathers the executors whose latest report matches this one and runs the same policy check used for proposals. If enough agree, the shared
StatusRecordis updated. - If not enough agree and another report differs for the same version, the shared status becomes
DISAGREE. Only the contract setsDISAGREE. 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.
| Event | Keys | Data |
|---|---|---|
| ValueChanged | value_key, prefix | digest, schema_ref, storage_class, version |
| StatusAttested | value_key, executor | observed_revision, applied_hash, condition |
| StatusChanged | value_key, prefix, executor | observed_revision, observed_digest, condition, status_version |
| ProposalCreated | proposal_id, proposer | action, prefix_hash |
| ProposalApproved | proposal_id, approver | |
| ApprovalSigned | proposal_id, approver | Marks an intent-bound approval. A fresh one also emits ProposalApproved; upgrading an earlier raw approval emits only this. |
| HumanReviewRequested | proposal_id, requester | |
| ProposalCommitted | proposal_id | action |
| PolicyChanged | prefix_hash, action | threshold, role |
| RoleChanged | account | active, role |
| LockAcquired | lock_key, holder | operation_id, version, acquired_at, ttl |
| LockReleased | lock_key, holder | version |
| LockForceUnlocked | lock_key, previous_holder | version |
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:
| Reason | Meaning |
|---|---|
| commit: policy unmet | The approvals do not satisfy the policy — too few, too few intent-bound humans, wrong roles, or one operator over its cap. |
| commit: no policy | Nothing 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 gov | The ratchet refused a SET_POLICY that would loosen a governance gate. |
| commit: … out of scope · role not global | The proposal's prefix is not where its target lives. |
| set_value: stale version · value: stale version · release: stale version | Compare-and-set lost: someone wrote first. Re-read and retry. |
| value: governed · commit: value is open | Wrong path for the prefix's SET_VALUE mode. |
| value: lease expired · value: not holder · acquire: held | Lease problems on the open path. |
| acquire: prefix rebind · value: prefix rebind | The key is already bound to a different prefix. |
| status: wrong role · status: future revision · status: revision regress | An 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: duplicate | The account rejected a signature, or this human already signed. The whole batch reverts. |
| policy: gov not open · policy: gov needs quorum | The 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 FoundryUsually 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, manifestThe 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.
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, alongsideSET_VALUE,TOMBSTONE_VALUEandSET_STATUSas needed. Spare prefixes are reserved. - Human signers are held by different people, and
operator_idreflects 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_VALUEfor 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:
- Add the tag to
actionsinlib.cairoand decide whether it belongs inis_governance_action. Governance actions get the floor and the ratchet; fast paths stay out. - Add a fixed-arity decoder to
governance.cairo. - Add a branch to
committhat asserts the target lives in the proposal’s prefix before touching storage. - Emit an event for the state change.
- Add snforge tests, including the attacks: a weaker prefix reaching in, a wrong arity, a replayed approval.
- Run
pnpm sync-artifactsso@bitcd/coreembeds the new class, and add the tag toACTIONin@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.