bitcd.yaml
bitcd.yaml declares the signers, the quorums that govern each kind of change, and the requests people can make. The bitcd CLI turns the file into a deployed contract with its policies seeded, executor configuration, and a governed copy of the definition. It refuses the unsafe setups listed below before anything is deployed.
orgmanifest/v1) is a governed value at manifest.key, by convention sys/manifest. Changing it needs its own quorum with at least one human. Executors check it against its fingerprint and stop on a mismatch.Sections
Every file starts with schema: bitcd/v1. These sections are the whole vocabulary. An unknown field is an error, except inside free-form data you supply: a connector's config, an enum's map.
| Section | Required | What it holds |
|---|---|---|
| schema · org · version | yes | The schema id, your organization's name, and your own version number for the file. |
| naming | yes | Always literal: names are used as-is, and every name that ends up on chain — keys, prefixes, roles, schemas — must fit in 31 characters. |
| ceremony | yes | renounce: true hands the contract over to its quorums when setup finishes. See deploying for why to start with false. |
| cast | yes | The roles in use and the signers: each with an address, human, an operator (who runs the key) and a role. |
| storage | yes | The bucket for documents and endpoint_env, the name of the environment variable that holds the store's endpoint (default BITCD_S3_ENDPOINT). |
| connectors | no | The systems executors manage: a type (k8s-rbac, aws-iam, terraform…), the executor role, the executors holding it, non-secret config and credentials — environment-variable names only (both default to empty). |
| intent_types | * | Kinds of request people can make against a connector, like “give this account read access to secrets”. See below. |
| values · fleets · locks | * | Governed things that need no connector. See below. |
| reserved_prefixes | no | Spare prefixes you may want later, each with the quorum allowed to set their policies up. |
| baseline | no | What executors may touch: unmanaged: untouched and drift_scope: managed-keys. |
| manifest | yes | Where the compiled definition lives (key, required; prefix, defaulting to the key's first segment) and the quorum that may change it. |
* A file needs at least one intent type, value or fleet. Locks alone are not enough.
A quorum is always four fields: threshold, role, min_humans and max_per_operator (0 means no cap). allow_open cannot be written by hand. Open access exists only where the file's structure calls for it: a fleet's lease, a lock, or one lock's value.
Example
The development organization beside the CLI, bitcd-core/core/example/bitcd.yaml, with addresses shortened and each signer on one line.
schema: bitcd/v1
org: chainops
version: 1
naming: literal # every name fits in 31 characters — checked at compile
ceremony:
renounce: true # give up ownership when setup finishes; after that, new
# policies need a prefix with its own SET_POLICY gate
cast:
roles: [OPERATOR, EXEC_K8S, OPS_PROPOSER]
signers:
agent1: { address: "0x078662…c8b1", human: true, operator: OP_A, role: OPERATOR }
agent2: { address: "0x049dfb…f38e", human: true, operator: OP_B, role: OPERATOR }
agent3: { address: "0x04f348…27d3", human: false, operator: OP_A, role: OPERATOR }
# shares agent1's operator, so the per-operator cap applies to them together
k8s-bridge: { address: "0x003736…e06f", human: false, operator: OP_K8S, role: EXEC_K8S }
# the connector's own executor role
ops-console: { address: "0x014fc4…492d", human: false, operator: OP_OPS, role: OPS_PROPOSER }
# no policy names this role: the web app's key can propose, but its approvals never count
connectors:
dev-cluster:
type: k8s-rbac # which connector package does the work
role: EXEC_K8S
executors: [k8s-bridge]
config: # non-secret; copied into the manifest as-is
context: k3d-bitcd-rbac
credentials: {} # environment-variable names only — never the secrets themselves
storage:
bucket: bitcd-tfstate
endpoint_env: BITCD_S3_ENDPOINT
intent_types:
k8s-access: # “give this account these permissions, for this long”
connector: dev-cluster
prefix: k8s/rbac
schema: k8srbacgrant/v1
key: "k8s/rbac/{ns}/{name}" # longest possible key: 10 + 6 + 13 = 29 characters
params:
ns: { type: enum, label: Namespace, values: [team-a] }
name: { type: name, label: Grant name, max_len: 13, format: key-slug }
subject: { type: subject, label: Who, kinds: [User, Group, ServiceAccount] }
pset:
type: enum
label: Permission set
values: [read-secrets]
map: # each named set maps to fixed provider data
read-secrets: { roleKind: Role, roleName: read-secrets }
ttl: # how long a grant lasts, in chain time (the executor revokes it)
required: false
default: 24h
max: 720h
quorum: # who approves a grant: two operators, one of them human,
threshold: 2 # at most one per operator
role: OPERATOR
min_humans: 1
max_per_operator: 1
revoke: same # revoking takes the same quorum as granting
attest: { k: 1 } # one executor report is enough
summon_human: false # a person already signs every grant here
mutable: true # this prefix's policies can be changed later
reserved_prefixes: # room to grow: only the policy-setting gate is created now
- prefix: aws/iam
set_policy: { threshold: 2, role: OPERATOR, min_humans: 1, max_per_operator: 1 }
baseline:
unmanaged: untouched # executors leave everything they don't manage alone
drift_scope: managed-keys
manifest: # where the compiled definition lives, and who may change it
key: sys/manifest
quorum: { threshold: 2, role: OPERATOR, min_humans: 1, max_per_operator: 1 }Intent types
An intent type is a request form. Its key template, built from parameters in {braces}, decides where each grant is stored. Its params decide what can be filled in. There is no expression language: each parameter is one of six types, and a new need becomes a new type.
| Type | Extra fields | Notes |
|---|---|---|
| enum | values, map | A fixed list of options (values is required). The optional map turns each option into fixed provider data, and must cover every option. |
| name | max_len, format | max_len is required (1–64). format is identifier (default), key-slug, email, hostname or arn. |
| subject | kinds, source | Who receives the grant. With kinds, a provider subject given as JSON {kind, name, namespace?}; with source: signers, an account address (a form offers the cast, but membership isn't enforced). |
| number | min, max, integer | integer defaults to true. |
| boolean | ||
| duration | min, max, presets | Durations like 30m, 24h, or seconds. |
Every parameter also takes label (required), help, required (default true) and default. A default is checked like a submitted value and must sit within the field's own min and max. Parameters used in the key must be enum or name and required, so the compiler can prove the longest key fits in 31 characters. Values are checked character by character when a request is made; format: key-slug is the stricter, lowercase form.
The rest of an intent type:
ttl, optional. Without it, requests cannot set a TTL. With it,maxis required,requireddefaults to false, and leaving outdefaultlets a request be permanent.quorum.revoke:sameby default, or its own quorum, never weaker.attest: how many executors must agree,{k: 1}by default.summon_human:truelets any voter demand a person's signature on one proposal, after which no quorum can commit without it. False when left out. See [person-reachable].mutable, always stated.mutable: falselocks the prefix's policies once setup ends and needsimmutable_ack: truebeside it.
Fleets, locks and values
Three sections need no connector. An agent fleet or a sign-off gate lives in the same file:
| Section | Fields | What gets set up |
|---|---|---|
| values | prefix, schema, key, quorum, revoke, summon_human, mutable, immutable_ack | A value changed only through quorum approval — a release-approval gate, say. SET_VALUE; a delete policy only if you give revoke; SET_POLICY only if mutable. |
| fleets | prefix, schema, key, quorum, meta, summon_human, lease {prefix, recovery} | A prefix where agents check each other's work. The value quorum; meta, the quorum for changing the fleet's own policies (required, and never weaker than the value quorum); and a work lease — anyone can take it, and a quorum can break it. |
| locks | prefix, recovery, value {schema, key}, mutable | A lock for coordination, like leader election or Terraform state locking: anyone can take it, and the recovery quorum can force it open. One lock may also carry a value that only the lock holder writes, like a Terraform state pointer. mutable defaults to false here. |
At most one prefix can have a lease-written value. Such a value has no quorum behind it, so a takeover has no governed way back; the compiler allows one for that reason. The contract enforces policies per prefix: the keys and schemas you declare describe what you intend to store there, and anyone meeting a prefix's policy can write other keys under it.
Keys in these sections are fixed strings, must sit under their prefix, and schemas follow name/vN. Values need an explicit mutable (with immutable_ack: true when false); summon_human defaults to false.
What the compiler seeds
From the file, the compiler builds the complete list of signers and policies to seed:
- Each intent type:
SET_VALUE(its quorum),TOMBSTONE_VALUE(its revoke quorum),SET_STATUS(its executor role andattest.k), andSET_POLICYif mutable. - Values, fleets and locks: the policies in the table above. Mutable locks get a
SET_POLICYgate; a fleet's lease prefix does not. A derived policy-changing gate always requires at least one human. - Executor reports (
SET_STATUS): the executor role,threshold=attest.k, no human requirement, and a per-operator cap of 1 whenkis above 1. - Global gates for managing signers and policies (
'GLOBAL'SET_ROLEandSET_POLICY) at the manifest quorum's strength, so signers can be rotated after setup. - The manifest's own
SET_VALUE,TOMBSTONE_VALUEandSET_POLICY, and aSET_POLICY-only gate on each reserved prefix.
Once ownership is renounced, a prefix without a SET_POLICY gate can never get new policies. The declared prefixes plus the reserved ones are the boundary of the organization from then on. Anything outside them needs a new deployment.
The CLI
One command, bitcd: bitcd-core/core/bin/bitcd.mjs, part of @bitcd/core. It needs only the bitcd-core checkout.
The commands that talk to a chain or a store share these options. validate and plan need none of them; diff needs no keystore. An unknown option is refused before anything happens.
| Option | Environment | Default |
|---|---|---|
| --rpc | BITCD_RPC | http://localhost:5051 |
| --deployment | BITCD_DEPLOYMENT_FILE | ./deployment.json |
| --keys | BITCD_KEYS_FILE | — a keystore, JSON {name: {address, pk}} |
| --s3-endpoint | BITCD_S3_ENDPOINT | http://localhost:4566 |
| S3 credentials | BITCD_S3_REGION, AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, AWS_SESSION_TOKEN | region us-east-1; without keys, the local dev pair for a local or plain-http endpoint and the AWS SDK's own chain otherwise |
| Command | What it does |
|---|---|
| validate <file> | Checks the schema, runs the security checks and a full compile. Offline — nothing touches a chain. |
| plan <file> [-o dir] | Compiles and prints what setup will seed. With -o, also writes ceremony.json, manifest.json and per-connector config and environment templates. |
| apply-ceremony <file> [--owner name] | Deploys the organization's contract (or resumes a deployment in progress), seeds signers and policies, and renounces if the file says so. The owner is the keystore entry genesis unless you pass --owner. Existing entries are skipped when they match and stop the run when they differ. Refuses an instance that's already renounced, and a deployment record that points at no contract on this chain. |
| publish-manifest <file> [--proposer name] [--no-commit] | Proposes the compiled definition as the new manifest and, unless you pass --no-commit, approves and commits it with the signers in the keystore — refusing up front if they can't. The new definition only replaces the live one after the commit. With --no-commit it waits for people to approve with their own keys, and running it again before they have reports the open proposal instead of proposing twice; once it has committed, run it again to put it in place. If the chain already has the same definition it proposes nothing, and only makes sure the stored copy is the committed one. |
| diff <file> | Compares the file with the chain: ownership against ceremony.renounce, the policies, the signers — including any still active on chain that the file no longer lists — and the published manifest, and names the class the contract runs. Prints CLEAN (exit 0) or DIFFS FOUND (exit 2, which includes a manifest it can't read or verify); an invalid file or another failure exits with 1. |
| propose-changes <file> [--proposer name] [--no-commit] | Brings the chain in line with an edited file, through governance only: new and changed signers, deactivating signers the file no longer lists, policies made stricter, new policies on reserved prefixes, then a republished manifest. It checks the whole plan before sending anything — a weakening, a missing gate, or a step the keystore couldn't approve at that point refuses the run with nothing sent. Each change is still its own transaction; if one fails for another reason, run it again and it picks up where it stopped. By default it approves each change with the keystore. With --no-commit it proposes one change at a time and stops, so people can sign it; run it again after the commit for the next change, and finally the manifest. The keystore then needs no approvers — only a signer who stays active through the whole plan to propose each step (the check before sending makes sure of that). |
Before you rely on it
- It signs with a local keystore. Writing commands use the
--keysfile;publish-manifest,propose-changesand--approve-devapprove and commit with the signers in it that are active on chain. For real signers, propose without approving (publish-manifest --no-commit,propose-changes --no-commit) and have people sign with their own keys. - Setup does not read back what it wrote. Run
apply-ceremonywithrenounce: false, check withbitcd diff, then setrenounce: trueand run it again. Onboarding walks through it. - A deployment record belongs to one chain. Every command that talks to the chain checks that a contract lives at the recorded address and, when the record names a class, that it runs that class. Otherwise it refuses. After restarting a devnet, delete the old record. Keep one record per network.
- Removing a policy from the file does not remove it from the chain. Removed signers are deactivated. Policies can only be made stricter, never deleted.
- A manifest approved outside the CLI needs one more step. After it commits, run
publish-manifestagain to put it in place; executors halt until you do. - The store endpoint comes from
--s3-endpoint, else the variable named instorage.endpoint_env, elseBITCD_S3_ENDPOINT. - The TTL clock starts when the request is made, not when it is approved.
versionin the file is your own number; the chain keeps its own revision count.
Lints
validate, plan and every command that loads the file run these checks. Each one is an error and stops the command. Together they check that every quorum can be met by the declared cast, not that the right people hold the keys. The code in brackets starts the message.
| Code | What it catches |
|---|---|
| [executor-separation] | Executors reaching beyond their job: connectors sharing an executor role, an executor role named on a governance gate, or declared executors that don't match who holds the role. |
| [threshold-reachable] | A threshold higher than the number of signers holding the role. |
| [humans-reachable] | A min_humans higher than the number of human signers holding the role. |
| [operator-diversity] | A per-operator cap that leaves too few usable signers — or too few usable humans — given how the signers are actually spread across operators; and, when attest.k is above 1, executors that don't come from enough distinct operators. |
| [attest-reachable] | An attest.k larger than the connector's number of executors. |
| [person-reachable] | An intent type, value or fleet whose quorum needs no human (min_humans: 0) without a way to reach one: it needs summon_human: true, so agents can ask for a person, and a human signer with the same role who can answer. |
| [policy-gate-not-weaker] | A fleet whose policy-changing quorum is weaker than its value quorum (quorums with different roles count as not comparable, so they fail too). |
| [revoke-not-weaker] | An intent type whose revoke quorum is weaker than its grant quorum in any field, or uses a different role. |
| [lock-recovery] | Leases and locks anyone can take: a fleet's recovery quorum weaker than its value quorum, or recovery quorums that differ between such prefixes. |
| [single-open-value] | More than one lock carrying a value. |
| [prefix-owner] | Two parts of the file claiming the same prefix. |
| [31-char] | A key template whose longest possible key wouldn't fit in 31 characters, or that uses a parameter that's undeclared, optional, the wrong type, or an enum with unsafe values. |
The schema checks catch the rest without a code: secret-looking values anywhere and secret-looking field names outside credentials (matched by pattern, so not exhaustive), a manifest quorum without a human, an intent type or value that does not state mutable, and any unknown field.
After setup
Later changes go through propose-changes and the quorums you declared. You can make governance stricter, add signers, change policies on mutable prefixes and start using a reserved prefix. You cannot create policies on a prefix without its own SET_POLICY gate, and you cannot loosen a governance policy. An organization that outgrows its prefixes needs a new deployment, so reserve a few.