bitcd

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.

Changing the definition needs approval. The compiled definition (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.

SectionRequiredWhat it holds
schema · org · versionyesThe schema id, your organization's name, and your own version number for the file.
namingyesAlways literal: names are used as-is, and every name that ends up on chain — keys, prefixes, roles, schemas — must fit in 31 characters.
ceremonyyesrenounce: true hands the contract over to its quorums when setup finishes. See deploying for why to start with false.
castyesThe roles in use and the signers: each with an address, human, an operator (who runs the key) and a role.
storageyesThe bucket for documents and endpoint_env, the name of the environment variable that holds the store's endpoint (default BITCD_S3_ENDPOINT).
connectorsnoThe 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_prefixesnoSpare prefixes you may want later, each with the quorum allowed to set their policies up.
baselinenoWhat executors may touch: unmanaged: untouched and drift_scope: managed-keys.
manifestyesWhere 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.

TypeExtra fieldsNotes
enumvalues, mapA fixed list of options (values is required). The optional map turns each option into fixed provider data, and must cover every option.
namemax_len, formatmax_len is required (1–64). format is identifier (default), key-slug, email, hostname or arn.
subjectkinds, sourceWho 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).
numbermin, max, integerinteger defaults to true.
boolean
durationmin, max, presetsDurations 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, max is required, required defaults to false, and leaving out default lets a request be permanent.
  • quorum.
  • revoke: same by default, or its own quorum, never weaker.
  • attest: how many executors must agree, {k: 1} by default.
  • summon_human: true lets 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: false locks the prefix's policies once setup ends and needs immutable_ack: true beside it.

Fleets, locks and values

Three sections need no connector. An agent fleet or a sign-off gate lives in the same file:

SectionFieldsWhat gets set up
valuesprefix, schema, key, quorum, revoke, summon_human, mutable, immutable_ackA 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.
fleetsprefix, 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.
locksprefix, recovery, value {schema, key}, mutableA 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 and attest.k), and SET_POLICY if mutable.
  • Values, fleets and locks: the policies in the table above. Mutable locks get a SET_POLICY gate; 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 when k is above 1.
  • Global gates for managing signers and policies ('GLOBAL' SET_ROLE and SET_POLICY) at the manifest quorum's strength, so signers can be rotated after setup.
  • The manifest's own SET_VALUE, TOMBSTONE_VALUE and SET_POLICY, and a SET_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.

OptionEnvironmentDefault
--rpcBITCD_RPChttp://localhost:5051
--deploymentBITCD_DEPLOYMENT_FILE./deployment.json
--keysBITCD_KEYS_FILE— a keystore, JSON {name: {address, pk}}
--s3-endpointBITCD_S3_ENDPOINThttp://localhost:4566
S3 credentialsBITCD_S3_REGION, AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, AWS_SESSION_TOKENregion us-east-1; without keys, the local dev pair for a local or plain-http endpoint and the AWS SDK's own chain otherwise
CommandWhat 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 --keys file; publish-manifest, propose-changes and --approve-dev approve 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-ceremony with renounce: false, check with bitcd diff, then set renounce: true and 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-manifest again to put it in place; executors halt until you do.
  • The store endpoint comes from --s3-endpoint, else the variable named in storage.endpoint_env, else BITCD_S3_ENDPOINT.
  • The TTL clock starts when the request is made, not when it is approved. version in 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.

CodeWhat 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.