Concept
A governed change needs approval, an executor to apply it and a way to check the result. bitcd keeps the approval rules and the record of changes in a contract on Starknet, where no actor in the workflow can edit them.
The problem
Approval rules usually live where the actors can reach them. Branch protection gates a pull request, but the CI job that applies it holds more access than the reviewers. An agent’s tool allowlist is a file the agent can edit. A kill switch cut off from its source of truth keeps acting on stale permissions.
bitcd moves the rules out of reach. A Cairo contract holds, per namespace, who approves, how many approvals are needed, how many must come from people and how many may come from one operator. After setup the owner renounces the contract. From then on a policy change needs a quorum. There is no admin key.
What the contract holds
A key/value store with versions, leases, compare-and-set and an event stream. Each value is the fingerprint of an off-chain document, a schema reference and a version. The document itself lives in a store you run, such as S3. Governed writes pass the namespace’s policy first. Readers fetch the document and check it against the fingerprint before acting.
A change, step by step
| Step | Who | What happens |
|---|---|---|
| Propose | any active signer | The fingerprint, the key and the expected version go on chain. The proposal stays there whether or not it commits. |
| Approve | signers | Machines approve with a transaction. People sign a SNIP-12 message that names the exact change. The signature is valid for that change only. |
| Commit | anyone | The contract checks the policy: threshold, role, human floor, per-operator cap. Too few approvals return policy unmet. |
| Apply | executors | They wake on the commit event, fetch the document, re-hash it, refuse a mismatch and apply it: a binding, an IAM policy, a Terraform run. |
| Report | executors | Observations go through set_status, which cannot touch desired state. With k-of-n attestation, reports that disagree produce DISAGREE. |
At any point before commit, an eligible voter can request a human on one proposal. From then on it needs a signed human approval that machine votes cannot outvote. The request cannot be withdrawn.
Building blocks
A new workflow is a namespace, a document schema and an off-chain executor. The contract does not change.
| You need | bitcd gives you |
|---|---|
| A step that needs sign-off | A policy on a namespace and action: threshold, role, min_humans, max_per_operator. Checked at commit. |
| A human on demand | request_human_review: the proposal then needs a signed human approval no quorum can outvote. |
| Mutual exclusion | Leases: an optional timeout, a version that fences stale holders, compare-and-set release, a quorum-approved force-unlock. |
| Timers | A grant’s expiry is part of the approved document. Executors revoke on chain time, not on a local clock. |
| Triggers | Every change is an event. Executors subscribe; consoles and indexers fold the same stream. |
| Concurrency control | Every value has a version and every write is compare-and-set. A stale proposal is refused. |
| Worker agreement | k-of-n attestation. Reports that disagree for one version stay at DISAGREE until an agreeing group clears it. |
| Undo, revoke, kill | A delete is a proposal under the deletion policy. Executors treat the tombstone as a revoke. |
| An audit trail | The chain, settled on Ethereum. Agent fleets add a hash-chained log of every vote, refusal and escalation. |
The rules
Desired and observed are separate
Desired state is the fingerprint, the version and a tombstone flag. Observed state is what executors found after applying. Desired changes through proposals, or through a lease on an open prefix. Observed changes only through set_status, which has no path to desired. A failed apply cannot rewrite the goal.
This holds as long as executor accounts stay out of the quorums. The bitcd.yaml lints enforce that; the contract alone does not. Executors still hold real credentials for the systems they manage. Scope those credentials.
Policy is data
A policy is six fields on a (prefix, action) pair: threshold, role, min_humans, max_per_operator, allow_open, exists. There is no scripting language. Policies change through the same approval process as everything else. The governance gates, SET_POLICY, SET_ROLE, FORCE_UNLOCK and TOMBSTONE_VALUE, can only get stricter. A prefix set up without a SET_POLICY gate cannot gain one later, so reserve spare prefixes before setup ends.
Verify on read
The chain holds the fingerprint, a schema reference and a storage class. Every bitcd reader re-hashes what it downloads and refuses a mismatch: the executor attests FAILED instead of applying, and the manifest loader answers HALT. The store does not need to be trusted. A short store outage can be covered by a grace period you set. A mismatch gets none. A reader you write yourself has to do the same check.
People sign the change
Where a policy sets min_humans to one or more, a person signs a SNIP-12 message naming the proposal, the action, the prefix and a hash of the parameters. The contract verifies it in approve_sigs by asking the signer’s own account. Only signed approvals count toward min_humans. A plain approve from a human’s account counts as a machine.
The wallet shows the parameters as a hash. A signing tool decodes the stored parameters, fetches the document, checks it against the fingerprint and shows it before asking for a signature. approvalTypedData, signApproval and approveWithSigs in @bitcd/core build, sign and relay the message, and assertRoundTrip checks the hash against the contract first.
Asking for a human
Any signer eligible under a proposal’s policy can call request_human_review. The proposal then needs at least one signed human approval on top of its policy. Machine approvals cannot outvote it and the request cannot be withdrawn. Paging a person is a separate component. The bitcd.yaml lints check that an eligible human signer exists for the policy.
Several executors
A policy can ask for k of n executors to agree before the shared status changes. If reports for one version differ and no group reaches k, the status becomes DISAGREE on chain. A later agreeing group clears it. Older reports cannot overwrite a newer result. Run executors under different operators and set max_per_operator; otherwise the agreement means little. k = 1 works.
Leases and expiry
A lock has a lease with an optional timeout, a version that fences stale holders, compare-and-set release and a quorum-approved force-unlock. Locks back Terraform state locking and a Kubernetes-style leader lease.
A grant’s expiry is part of the document the quorum approved. When chain time passes it, the executor revokes the grant and reports expired. If the executor is down, revocation waits until it is back.
Deleting is a governed write
A delete is a proposal under the prefix’s TOMBSTONE_VALUE policy. It is recorded, not erased. On an open prefix the lease holder deletes directly. Executors treat a tombstone as a revoke: remove the binding, return Terraform inputs to their defaults, halt whatever loads that manifest.
Drift
Executors reconcile: read desired, verify, apply, read the live object back, report. A change made by hand shows up on the next pass as OUTOFSYNC, is put back and is reported. Live objects are reduced to a stable shape before hashing, so server-added fields do not trigger it. The executor only looks at what it manages. The periodic check can be turned off.
Anyone can commit
Any active signer can propose and approve. The check is at commit, which anyone can call. The contract evaluates the policy at that moment and applies the change or refuses with a reason. No committer role exists to lose or compromise. Proposals and approvals stay on chain whether or not they commit. An agent that proposes widening its own allowlist is refused with policy unmet, and the attempt stays visible.
Events
Every change is an event. Executors subscribe over the RPC’s WebSocket. The indexer and consoles fold the same stream. Events do not carry everything, so a reader that needs the full record also reads the contract. The chain is the source of truth, not any component’s database.
Evidence
Agent fleets keep a hash-chained log of every vote, refusal and escalation with the reason given. Each entry commits to the one before it. To defend it against a rewrite that recomputes the chain, checkpoint it somewhere the agents cannot reach. The log records what an agent said; whether the reasoning was sound is your call.
Setup, then no admin
A temporary owner deploys the contract, seeds signers and policies and renounces. Do it first with renouncing off, read everything back, then renounce. After that, policy and signer changes go through quorums and values follow their prefix’s rules. The bitcd CLI builds the setup from one file and refuses an unsafe one before anything is deployed.
The organization in one file
Signers and roles, executors and one block per kind of change go in bitcd.yaml. This block declares one kind:
intent_types:
k8s-access: # give this account these permissions, for this long
connector: dev-cluster # the executor that applies it
prefix: k8s/rbac
schema: k8srbacgrant/v1
ttl: # chain time; the executor revokes on expiry
default: 24h
max: 720h
quorum: # two operators, one of them human, one per operator
threshold: 2
role: OPERATOR
min_humans: 1
max_per_operator: 1
revoke: same # taking it away needs the same approvals
attest: { k: 1 } # one executor report is enough
mutable: true # this prefix's policies can change later
The bitcd CLI validates the file offline, shows the policies it will seed, runs the ceremony and publishes the compiled manifest as a governed value. @bitcd/core holds the client, SNIP-12 approvals, the executor and voter engines, event folds and the ceremony. Your project links it from a side-by-side clone.
Why Starknet
The gate has to sit outside the control of the actors it governs, including bitcd’s maintainers. A public chain does that. Starknet adds four things bitcd relies on.
- Every account is a contract. A signer can be a hardware wallet or a team multisig counted as one approver. The contract asks the account whether a signature is valid (SRC6).
- Typed messages. SNIP-12 lets a person sign a readable description of a change instead of hex.
- Settlement on Ethereum. The history your auditors read is held by neither you nor us.
- Fees follow change, not traffic. Governed changes are rare and executors stay quiet while things are healthy. Reads are RPC calls and cost nothing.
Cost
Each account pays for the transactions it sends, including one that is included but reverts.
| What happens | Transactions | Who pays |
|---|---|---|
| A governed change | propose, one approve per machine signer, commit | The account submitting each call |
| People approving | Up to 64 signatures per approve_sigs call | The relayer; signing is free |
| An executor report | One set_status after a change, repair or retry | The executor |
| A lease | acquire_lock, then release_lock | The holder |
| Reading, watching, checking | None | Nobody |
You run the document store, the executors and the readers. Application traffic uses configuration already applied and adds no transactions.
Guarantees
Authorization and availability are separate. The contract checks the approvals at commit. How fast a change reaches the live system depends on the chain, your RPC provider, the approvers and the executors.
| Property | What holds |
|---|---|
| Authorization | A governed change cannot commit without the approvals its policy requires. After renouncing, no key can bypass the policy. |
| Integrity | A document that does not match its fingerprint is refused. The reader stops instead of using it. |
| Latency | A transaction is accepted on Starknet within seconds and settles on Ethereum once proven. A change also waits on its approvers. |
| Reaction | In watch mode, events wake executors as a commit lands. A slower periodic check covers the rest. |
| Chain outage | What executors applied stays in place. New changes, drift repair and revoking expired grants wait for the chain. |
| Expiry | Lease and grant timeouts run on chain time. |
What it cannot do. No step lands while Starknet is not producing blocks, break-glass included, since break-glass is a quorum too. It cannot make an executor honest; independent executors can be compared, and a split is flagged, but a lone or colluding executor can misreport. There is no recovery key. Lost signers are replaced through the governance quorum; if that quorum is lost, what it guards is locked for good.