bitcd

Onboarding

Your own organization: who signs, what needs whose approval, deploying the contract, checking it and handing it to its quorums. Three routes, a bitcd.yaml with the CLI, your own code, or plain contract calls, end with the same contract.

Rehearse on a local chain first. Every step works on a local devnet. On a public network the commands stay the same; the accounts, the RPC and the storage change. Renouncing cannot be undone, so run the whole setup locally, through renouncing, before doing it anywhere else.

Pick a route

All three routes deploy the same contract and write the same signers and policies. They differ in how much is checked for you.

A · CLIB · SDKC · By hand
Checks the setup before deployingyes — schema and security checksnono
Derives the policies each part needsyesyou list themyou list them
Compares the chain with your intentbitcd diffyou read backyou read back
Publishes the organization's definitionpublish-manifestnot by itselfnot by itself
Later changespropose-changesproposals from codeproposals by hand

Executors need route A’s published definition. A deployment made with B or C can adopt the CLI later; bitcd diff reports whether a bitcd.yaml matches the chain.

Before you start

You needForNotes
Node 22 and pnpmroutes A and Bpnpm through corepack; the repos pin their pnpm version.
Side-by-side clonesroutes A and Bbitcd-core. Your own code links @bitcd/core from it by path, so the layout matters.
Dockerthe local rehearsalThe compose file in the bitcd-core checkout runs a Starknet devnet on :5051 and an S3-compatible store on :4566.
sncast (Starknet Foundry)route CWith Scarb 2.19 if you declare the contract from source.
An S3-compatible bucketroute A, and anything with documentsThe chain stores fingerprints; the documents themselves live in the bucket. It doesn't have to be trusted — readers check every document against the chain.
One account per signerevery routeAny Starknet account contract that implements SRC-6 signature checks. Plus a funded account to act as the temporary owner during setup.
$ mkdir bitcdorg && cd bitcdorg
$ git clone git@github.com:bitcdorg/bitcd-core.git
$ npm install -g @bitcd/core starknet @aws-sdk/client-s3   # the bitcd command; the SDK is for the document store

The bitcd command ships with @bitcd/core; from an unreleased checkout, node bitcd-core/core/bin/bitcd.mjs is the same command. It covers every step here. Keep bitcd.yaml, the keystore and the deployment record in their own directory, acme-org/ beside the clones.

Design the organization

Decide who holds which key, what needs whose approval and how to recover from a lost key. The middle column is where each answer goes in bitcd.yaml.

QuestionIn the fileWorth knowing
Who can approve? The people and agents who hold keys.cast.signersMark people human: true. Only their signed approvals count toward min_humans.
Who controls each key?operatorKeys run by the same person or team share an operator. A max_per_operator below the threshold then stops one operator from filling a quorum alone — but only if this reflects who really holds the keys.
What kinds of signer are there?cast.roles, roleEach signer has one role, and each quorum counts one role. Give executors a role of their own, so their keys can report status but never approve.
What will you govern?values, intent_types, fleets, locksEach becomes a prefix — app/release, k8s/rbac — with its own policies. Keys, prefixes, roles and schema names are each at most 31 ASCII characters.
Who has to agree?quorumFour fields per change: threshold, role, min_humans and max_per_operator (0 means no cap). Approvals over an operator's cap don't just go uncounted — they make the commit fail. Keep min_humans at or below threshold, and make sure each quorum can still be met when someone loses a key.
Who changes the rules?manifest.quorumThe quorum over the organization's own definition, and over adding or removing signers. It must include a human.
What might you need later?mutable, reserved_prefixesOnce ownership is given up, policies can only be changed where a prefix has a policy-changing gate, and no new prefix can be added. Mark prefixes mutable and reserve a few spare ones.
Where do documents live?storageThe bucket name, and the name of the environment variable that holds the store's endpoint.

The example on this page is a small team, acme: three people and a CI bot, one governed value (the approved production release) and one spare prefix for feature flags. A release needs two approvals from different operators, at least one of them human.

Rehearse on a local chain

Start a devnet and the local store, then build a development keystore from the devnet's accounts. The keystore is JSON, {name: {address, pk}}. The entry named genesis is the temporary owner.

$ (cd bitcd-core && docker compose up -d --wait devnet ministack)
$ mkdir acme-org && cd acme-org
$ curl -s localhost:5051 -H 'content-type: application/json' \
    -d '{"jsonrpc":"2.0","id":1,"method":"devnet_getPredeployedAccounts"}' |
  node -e '
    const a = JSON.parse(require("fs").readFileSync(0)).result;
    const names = ["genesis", "alice", "bob", "carol", "release-bot"];
    const keys = Object.fromEntries(names.map((n, i) => [n, { address: a[i].address, pk: a[i].private_key }]));
    require("fs").writeFileSync("keys.json", JSON.stringify(keys, null, 2));'
$ node -p 'require("./keys.json").alice.address'   # the addresses to put in your file

These are public development keys and exist only on your devnet. A deployment record points at one contract on one chain. When you restart the devnet, delete deployment.json too, or the CLI resumes against a contract that no longer exists.

Route A: bitcd.yaml and the CLI

1

Describe the organization

acme as a file, addresses shortened. Start with renounce: false; ownership is given up in step 6, after the result is checked.

schema: bitcd/v1
org: acme
version: 1
naming: literal
ceremony:
  renounce: false                # keep the owner until you've checked the result

cast:
  roles: [OPERATOR]
  signers:
    alice:       { address: "0x…", human: true,  operator: OP_ALICE, role: OPERATOR }
    bob:         { address: "0x…", human: true,  operator: OP_BOB,   role: OPERATOR }
    carol:       { address: "0x…", human: true,  operator: OP_CAROL, role: OPERATOR }
    release-bot: { address: "0x…", human: false, operator: OP_CI,    role: OPERATOR }

storage:
  bucket: acme-bitcd
  endpoint_env: BITCD_S3_ENDPOINT

values:
  release-gate:                   # the approved production release
    prefix: app/release
    schema: release/v1
    key: app/release/prod
    quorum: { threshold: 2, role: OPERATOR, min_humans: 1, max_per_operator: 1 }
    revoke: same
    escalation: default
    mutable: true                 # its policies can be tightened later

reserved_prefixes:                # room to grow after ownership is gone
  - prefix: app/flags
    set_policy: { threshold: 2, role: OPERATOR, min_humans: 1, max_per_operator: 1 }

manifest:
  key: sys/manifest
  quorum: { threshold: 2, role: OPERATOR, min_humans: 1, max_per_operator: 1 }

The bitcd.yaml reference covers every section, including intent types and fleets.

2

Validate and plan

Both commands are offline. validate refuses a file whose quorums the cast cannot meet, where an executor could approve, or where a key might not fit. plan lists what setup will write.

$ bitcd validate bitcd.yaml
OK bitcd.yaml: org=acme v1 — 4 signers, 0 connector(s), 0 intent type(s), 1 governed value(s), 9 policies to seed, renounce=false
$ bitcd plan bitcd.yaml -o out/
ceremony for org=acme (renounce=false):
  set_signer alice  (OPERATOR, human)
  …
  policy GLOBAL/SET_ROLE=2-of-OPERATOR/h1
  policy GLOBAL/SET_POLICY=2-of-OPERATOR/h1
  policy sys·SET_VALUE=2-of-OPERATOR/h1
  …
  policy app/release·SET_POLICY=2-of-OPERATOR/h1
  policy app/flags·SET_POLICY=2-of-OPERATOR/h1 (reserved)

The compiler adds the GLOBAL and sys gates, for signers and the definition, at the manifest quorum's strength. -o also writes the seed and the compiled manifest as JSON.

3

Deploy and seed

Declares the contract if needed, deploys the instance with genesis as the temporary owner, writes each signer and policy, and records the instance in deployment.json.

$ bitcd apply-ceremony bitcd.yaml --keys keys.json
class hash : 0x621fbe86…
deployed at: 0x5bf4162b…
wrote ./deployment.json
  ok  set_signer alice  (0x42284fef)
  …
owner now: 0x3736286f…

If it stops halfway, run it again. It resumes against the recorded instance, skips entries that match and stops on one that differs.

4

Publish the definition

The compiled definition goes on chain as a governed value at sys/manifest, through the manifest quorum. It is a real change by your real signers, made while the owner can still fix mistakes.

With development keys, the CLI approves and commits with the signers in the keystore:

$ bitcd publish-manifest bitcd.yaml --keys keys.json

With real signers, only propose. Each person signs with their own key; then run the command again to put the committed definition in place:

$ bitcd publish-manifest bitcd.yaml --keys keys.json --no-commit
proposal #1: SET_VALUE sys/manifest (orgmanifest/v1 v1)
pending: 2-of-OPERATOR, min_humans 1, max_per_operator 1 — humans sign via `agentgate sign 1`. …

# each signer, on their own machine, signs proposal 1 with their own key: see the first change below

$ bitcd publish-manifest bitcd.yaml --keys keys.json   # after the commit: puts it in place

A signer reads sys/proposals/<id>.blob from the store, checks it against the fingerprint, then signs and relays the approval as shown in the first change. Whoever completes the quorum can commit; look for COMMITTED. Each signer pays for their own transaction unless a relayer submits the batch.

5

Compare the chain with the file

diff checks ownership against ceremony.renounce, reads every declared policy and signer back, looks for active signers the file does not list, and checks the published definition against the file.

$ bitcd diff bitcd.yaml
  contract 0x5bf4162b… runs class 0x621fbe866f…
CLEAN: yaml == ownership == chain policy table == active signer roster == on-chain manifest

Differences, including a published definition it cannot read or verify, are listed with exit code 2. An invalid file or another failure exits with 1. Run it after every step from here on.

6

Hand over to the quorums

Go through the checklist. Then set renounce: true and run the ceremony again. Every entry is skipped as already seeded, and ownership is given up.

$ bitcd apply-ceremony bitcd.yaml --keys keys.json
  skip set_signer alice (already seeded)
  …
renouncing ownership (no single actor remains)
owner now: 0x0 (renounced)
$ bitcd diff bitcd.yaml
  MANIFEST DIFFERS: chain v1 (org v1) digest 0x194d0d75… != local 0xd0d8d4d2… — publish-manifest (or reconcile the yaml)
$ bitcd publish-manifest bitcd.yaml --keys keys.json   # or --no-commit and sign, as in step 4
$ bitcd diff bitcd.yaml
CLEAN …

The definition fingerprints the whole file, so the changed renounce line makes it differ until it is published again. Another apply-ceremony refuses: ownership already renounced. From here on every change goes through a quorum.

Route B: from your own code

A Node project in acme-org/, also used by the first governed change on every route. @bitcd/core is linked by path from the clone, with starknet ^9 and @aws-sdk/client-s3 beside it. acme-org/package.json:

{
  "name": "acme-org",
  "private": true,
  "type": "module",
  "dependencies": {
    "@bitcd/core": "^0.1.0",
    "starknet": "^9.0.0",
    "@aws-sdk/client-s3": "^3.0.0"
  }
}
$ pnpm install

onboard.mjs. Every policy is listed by hand, including the GLOBAL and sys gates the CLI would add, so the CLI can be adopted later.

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

const keys = JSON.parse(readFileSync("keys.json", "utf8"));
const p = provider();                                 // BITCD_RPC, default http://localhost:5051
const owner = account(keys.genesis, p);                // the temporary owner

const OPS = str("OPERATOR");
const quorum = policySpec(2, OPS, 1, 1, false);        // threshold, role, min_humans, max_per_operator, allow_open
const person = (op) => signerSpec({ is_human: true, operator_id: str(op), role: OPS });

const signers = [
  { label: "alice", address: keys.alice.address, signer: person("OP_ALICE") },
  { label: "bob",   address: keys.bob.address,   signer: person("OP_BOB") },
  { label: "carol", address: keys.carol.address, signer: person("OP_CAROL") },
  { label: "release-bot", address: keys["release-bot"].address,
    signer: signerSpec({ is_human: false, operator_id: str("OP_CI"), role: OPS }) },
];
const gate = (prefix, action) => ({ label: `${prefix} ${action}`, prefix: str(prefix), action: ACTION[action], policy: quorum });
const policies = [
  gate("GLOBAL", "SET_ROLE"), gate("GLOBAL", "SET_POLICY"),     // managing signers later
  gate("sys", "SET_VALUE"), gate("sys", "TOMBSTONE_VALUE"), gate("sys", "SET_POLICY"),   // the definition
  gate("app/release", "SET_VALUE"), gate("app/release", "TOMBSTONE_VALUE"), gate("app/release", "SET_POLICY"),
  gate("app/flags", "SET_POLICY"),                        // the spare prefix
];

const { address } = await deployBitcd({ owner, deploymentFile: "deployment.json", log: console.log });
await runCeremony({ contract: bitcdContract(address, owner), provider: p, signers, policies, renounce: false, log: console.log });

// runCeremony doesn't read anything back; this is the check the CLI's diff does for you.
const view = bitcdContract(address, p);
for (const s of signers) {
  const on = await view.get_signer(s.address);
  if (!on.active || on.is_human !== s.signer.is_human
      || BigInt(on.operator_id) !== BigInt(s.signer.operator_id) || BigInt(on.role) !== BigInt(s.signer.role))
    throw new Error(`signer ${s.label} differs`);
}
for (const pl of policies) {
  const on = await view.get_policy(pl.prefix, pl.action);
  if (!on.exists || Number(on.threshold) !== pl.policy.threshold || BigInt(on.role) !== BigInt(pl.policy.role)
      || Number(on.min_humans) !== pl.policy.min_humans || Number(on.max_per_operator) !== pl.policy.max_per_operator
      || on.allow_open !== pl.policy.allow_open)
    throw new Error(`policy ${pl.label} differs`);
}
console.log("every signer and policy reads back as written");
$ node onboard.mjs

Make a first governed change and go through the checklist. Then renounce as a separate step, renounce.mjs:

import { readFileSync } from "node:fs";
import { provider, account, bitcdContract, loadDeployment } from "@bitcd/core/client";

const keys = JSON.parse(readFileSync("keys.json", "utf8"));
const p = provider(), dep = loadDeployment("deployment.json");
const contract = bitcdContract(dep.address, account(keys.genesis, p));
await p.waitForTransaction((await contract.renounce_ownership()).transaction_hash);
console.log(`owner now: ${BigInt(await contract.owner()) === 0n ? "0x0 (renounced)" : "still set"}`);
$ node renounce.mjs
owner now: 0x0 (renounced)

deployBitcd writes the record the CLI uses. To adopt the CLI, write a bitcd.yaml and run bitcd diff; once it matches, publish-manifest and propose-changes work.

Route C: by hand

The setup surface: a constructor taking the owner, set_signer, set_policy, and OpenZeppelin's owner and renounce_ownership. The setters are owner-only. sncast takes quoted short strings and struct literals in --arguments, so nothing is encoded by hand. Any Starknet tool that can invoke a contract works.

1

Declare and deploy

From acme-org/: import the owner account into sncast, declare the class once per network, deploy it. If the class is already declared, sncast prints its hash; deploy that. Compare it with classHash in bitcd-core/core/artifacts/MANIFEST.json.

$ RPC=http://localhost:5051
$ OWNER=$(node -p 'require("./keys.json").genesis.address')
$ sncast account import --name owner --type oz --address "$OWNER" \
    --private-key "$(node -p 'require("./keys.json").genesis.pk')" --url "$RPC"
$ (cd ../bitcd-core/contracts && sncast --account owner --wait declare --contract-name Bitcd --url "$RPC")
$ CLASS=0x621fbe86…   # the class hash declare printed
$ sncast --account owner --wait deploy --class-hash "$CLASS" --arguments "$OWNER" --url "$RPC"
Contract Address: 0x0082…
$ C=0x0082…           # the address deploy printed
$ echo "{\"address\":\"$C\",\"classHash\":\"$CLASS\",\"rpc\":\"$RPC\"}" > deployment.json

The last line writes the deployment record the SDK and the CLI read.

2

Write signers and policies

One call per signer and per (prefix, action) policy. One of each below; acme needs all four signers and the nine policies from route A's plan, including the GLOBAL and sys gates.

$ ALICE=$(node -p 'require("./keys.json").alice.address')
$ sncast --account owner --wait invoke --contract-address "$C" --function set_signer --url "$RPC" \
    --arguments "$ALICE, bitcd::Signer { active: true, is_human: true, operator_id: 'OP_ALICE', role: 'OPERATOR' }"
$ sncast --account owner --wait invoke --contract-address "$C" --function set_policy --url "$RPC" \
    --arguments "'GLOBAL', 'SET_ROLE', bitcd::Policy { threshold: 2, role: 'OPERATOR', min_humans: 1, max_per_operator: 1, allow_open: false, exists: true }"

The contract refuses an open or zero-threshold SET_POLICY, SET_ROLE, TOMBSTONE_VALUE or FORCE_UNLOCK gate written with exists: true. Nothing else is checked: a quorum nobody can meet, or a row with exists: false that gates nothing, is accepted.

3

Read everything back

$ sncast call --contract-address "$C" --function get_signer --url "$RPC" --arguments "$ALICE"
Response:     Signer { active: true, is_human: true, operator_id: 0x4f505f414c494345, role: 0x4f50455241544f52 }
$ sncast call --contract-address "$C" --function get_policy --url "$RPC" --arguments "'GLOBAL', 'SET_ROLE'"
Response:     Policy { threshold: 2_u32, role: 0x4f50455241544f52, min_humans: 1_u32, max_per_operator: 1_u32, allow_open: false, exists: true }

Roles and operators come back as short-string felts; 0x4f50455241544f52 is OPERATOR. Check every signer and policy you wrote.

4

Renounce, last

After a first governed change and the checklist:

$ sncast --account owner --wait invoke --contract-address "$C" --function renounce_ownership --url "$RPC"
$ sncast call --contract-address "$C" --function owner --url "$RPC"
Response Raw: [0x0]

From now on the setters answer Caller is not the owner. Governed changes by hand use propose, approve, approve_sigs and commit, with the parameter layouts on the contract page. A plain approve from a person does not count toward min_humans; only a signed approval through approve_sigs does.

A first governed change

Before renouncing, commit one real change through the quorum you rely on most, signed as in production, while the owner can still fix things. Route A's manifest step does this for the manifest quorum. acme's release gate is a plain value, outside the CLI's intent commands, so it is proposed from code. first-change.mjs, in the route B project:

import { readFileSync } from "node:fs";
import { provider, account, bitcdContract, loadDeployment, approveWithSigs, str } from "@bitcd/core/client";
import { ACTION, STORAGE_CLASS, digestOf } from "@bitcd/core/protocol";
import { SELECTORS } from "@bitcd/core/events";
import { s3Store } from "@bitcd/core/store";

const keys = JSON.parse(readFileSync("keys.json", "utf8"));
const p = provider(), dep = loadDeployment("deployment.json");
const view = bitcdContract(dep.address, p);
const store = s3Store({ bucket: "acme-bitcd" });           // BITCD_S3_ENDPOINT
await store.ensureBucket();

const PREFIX = "app/release", KEY = "app/release/prod";
const doc = JSON.stringify({ version: "1.4.0", approved: true });
const cur = await view.get_value(str(KEY));
// SET_VALUE params: key, fingerprint, schema, storage class, the version you expect to replace
const params = [str(KEY), digestOf(doc), str("release/v1"), STORAGE_CLASS.S3, Number(cur.version)];

// 1. Any active signer proposes; the id comes back in the ProposalCreated event.
const bot = bitcdContract(dep.address, account(keys["release-bot"], p));
const receipt = await p.waitForTransaction(
  (await bot.propose(ACTION.SET_VALUE, str(PREFIX), params)).transaction_hash);
const id = BigInt(receipt.events.find((e) => BigInt(e.keys[0]) === BigInt(SELECTORS.ProposalCreated)).keys[1]);
console.log(`proposal #${id}`);

// 2. The document and its claimed params wait beside the proposal, where signers read them.
await store.putText(`${PREFIX}/proposals/${id}.blob`, doc);
await store.putText(`${PREFIX}/proposals/${id}.json`, JSON.stringify({ params: params.map(String) }));

// 3. On a devnet: approve with development keys, then commit (anyone may, once the quorum is met).
await approveWithSigs({ p, contractAddress: dep.address, proposalId: id,
  action: ACTION.SET_VALUE, prefix: str(PREFIX), params,
  signers: [keys.alice, keys.bob], relayer: keys["release-bot"] });
await p.waitForTransaction((await bot.commit(id)).transaction_hash);

// 4. Only a committed document goes where readers look, and only while it's still the current one.
const now = await view.get_value(str(KEY));
if (BigInt(now.digest) === BigInt(digestOf(doc))) await store.putText(`${KEY}/spec`, doc);
$ node first-change.mjs
proposal #1

With real signers, stop after step 2 and have each person sign on their own machine: signApproval with their account, then approveWithSigs to relay, both from @bitcd/core. Anyone can commit once the quorum is complete. Then publish.mjs puts the document in place, after checking that the proposal is the committed version:

import { provider, bitcdContract, loadDeployment, str } from "@bitcd/core/client";
import { digestOf } from "@bitcd/core/protocol";
import { s3Store } from "@bitcd/core/store";

const [id] = process.argv.slice(2);
const PREFIX = "app/release", KEY = "app/release/prod";
const p = provider(), dep = loadDeployment("deployment.json");
const store = s3Store({ bucket: "acme-bitcd" });
const doc = await store.getText(`${PREFIX}/proposals/${id}.blob`);
const now = await bitcdContract(dep.address, p).get_value(str(KEY));
if (BigInt(now.digest) !== BigInt(digestOf(doc))) throw new Error(`proposal #${id} isn't the current committed ${KEY}`);
await store.putText(`${KEY}/spec`, doc);
console.log(`${KEY}/spec <- proposal #${id}`);
$ node publish.mjs 1

To read the document before signing, print it with its fingerprint:

$ node --input-type=module -e '
    const { s3Store, digestOf } = await import("@bitcd/core/store");
    const doc = await s3Store({ bucket: "acme-bitcd" }).getText(process.argv[1]);
    console.log(digestOf(doc)); console.log(doc);' app/release/proposals/1.blob

To see a refusal, commit with one approval, or with two from the same operator. Both fail with commit: policy unmet.

Sepolia and mainnet

The steps are the same on every network. What changes:

Local devnetPublic network
RPChttp://localhost:5051Your provider's URL in --rpc / BITCD_RPC.
AccountsPredeployed and funded.Create, fund and deploy an account for each signer and for the temporary owner. People can use wallet accounts. Machine accounts are deployed from a funded deployer with an account class already declared on the network, such as OpenZeppelin’s.
KeystoreEvery signer's development key.Only the temporary owner, plus an account allowed to propose. Humans sign with their own key or a wallet, never through a shared file.
The contract classDeclared by the first deploy.Declared once per network; check the class hash against MANIFEST.json.
StorageThe local store, test credentials.A real bucket, one per deployment — stored paths don't include the contract address. Set BITCD_S3_ENDPOINT (it defaults to the local store) and BITCD_S3_REGION. For credentials, set AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY (plus AWS_SESSION_TOKEN for temporary credentials), or leave the keys out to use the AWS SDK's own chain — profiles, SSO, instance and task roles. An empty BITCD_S3_ENDPOINT means AWS's own regional endpoint.
Deployment recordThrowaway.Keep one per network, and keep it safe. The CLI refuses a record whose contract it can't find on the chain behind --rpc, or whose class doesn't match.
Approvalspublish-manifest, propose-changes and --approve-dev approve with the development keys.publish-manifest --no-commit and propose-changes --no-commit, then people sign.
Where this has been run. The CLI route, validate, ceremony, manifest and later changes, is what the proof rig runs against a fresh devnet in CI. Treat the first public setup as a rehearsal on Sepolia with nothing depending on it.

Before you renounce

  • bitcd diff prints CLEAN, or your own read-back matched every signer and policy.
  • One governed change has gone through the quorum you rely on most, signed the way it will be in production.
  • ('GLOBAL', SET_ROLE) and ('GLOBAL', SET_POLICY) exist, so signers can be added, rotated and removed. For the CLI's tools, the manifest prefix's SET_VALUE, TOMBSTONE_VALUE and SET_POLICY too.
  • Every prefix whose policies may change has a SET_POLICY gate, and spare prefixes are reserved. No prefix can be added afterwards.
  • Each human signer is a different person, and each operator matches who controls the key.
  • Every quorum, including the manifest quorum, can still be met if one person loses their key.
  • The deployment record is kept. On a public network, the whole setup was rehearsed on Sepolia.

After setup

Edit bitcd.yaml, check it, and the CLI turns the difference into proposals:

$ bitcd diff bitcd.yaml
  SIGNER DIFFERS/MISSING: dave (0x23b7f487…)
  MANIFEST DIFFERS: …
$ bitcd propose-changes bitcd.yaml --keys keys.json
plan: 1 signer change(s), 0 policy change(s)
proposal #…: SET_ROLE dave (OPERATOR, human)
  …
org manifest committed: chain version 3, …
You want toWhat happens
Add or change a signerA SET_ROLE proposal through the GLOBAL gate.
Remove a signerDelete them from the file; they're deactivated, and their approvals stop counting, even on open proposals.
Tighten a policyA SET_POLICY proposal through that prefix's gate. Higher thresholds, more humans, a tighter per-operator cap.
Start using a reserved prefixReplace its reserved_prefixes entry with the real section; its policies are created through the gate reserved at setup.
Loosen a policy or change its roleRefused by the CLI before anything is sent. For the SET_POLICY, SET_ROLE, TOMBSTONE_VALUE and FORCE_UNLOCK gates, the contract refuses it too.
Govern a prefix you didn't reserveNot possible on this instance — it needs a new deployment.

propose-changes checks the plan against the chain first and republishes the definition last. Each change is its own proposal; a rerun picks up where it stopped. By default it approves with the keystore's signers. With real signers add --no-commit: one proposal at a time, people sign, run again for the next, ending with the definition.

$ bitcd propose-changes bitcd.yaml --keys proposer.json --no-commit
proposal #7: SET_ROLE dave (OPERATOR, human) — needs 2-of-OPERATOR, min_humans 1, max_per_operator 1; sign via `agentgate sign 7`, then run propose-changes again (0 more step(s) after this one, then the manifest)
$ bitcd propose-changes bitcd.yaml --keys proposer.json --no-commit   # before anyone has signed
waiting: proposal #7 (SET_ROLE dave (OPERATOR, human)) needs 2-of-OPERATOR, … — sign via `agentgate sign 7`, then run propose-changes again

The CLI reference has every option.

Good to know

  • The keystore is plain JSON with private keys. Fine on a devnet. On a public network keep it to the temporary owner and a proposer; people sign with their own keys.
  • What diff does not check. It compares what the file declares, ownership included. A policy removed from the file stays on chain, unreported.
  • Publish the definition from the file as it will stay. It fingerprints everything the file declares, comments and formatting aside, so any change, even the renounce flag, needs another publish.
  • Store settings must match. The CLI finds documents through BITCD_S3_BUCKET, which defaults to bitcd-tfstate. Set it to your storage.bucket.
  • Which contract gets deployed. The SDK and CLI take BITCD_SIERRA / BITCD_CASM if set, else a bitcd-core/contracts/target/dev build if one exists, else the bundled contract. The class hash is printed at deploy; check it against MANIFEST.json.
  • Transactions take time. Each setup step is a transaction; on the local devnet expect around ten seconds each.