bitcd

Connectors

A connector is the system an executor manages. In bitcd.yaml it has a type, an executor role and the executors that hold it; intent types bind requests to it. The executor fetches each committed document, checks it against the digest on chain, applies it and reports what it observed. @bitcd/core provides that step. The loop around it and the code that talks to your system are yours.

The declaration

The organization the proof rig deploys, e2e/bitcd.yaml, declares one connector and binds an intent type to it. Trimmed:

connectors:
  demo-iam:
    type: s3-demo            # a free name; your executor decides what it means
    role: EXEC_IAM           # this connector's executor role, and nobody else's
    executors: [exec1, exec2, exec3]
    config:                  # non-secret; published in the manifest as is
      live_prefix: iam
    credentials: {}         # environment variable names only

intent_types:
  iam-acct:
    connector: demo-iam
    prefix: iam/acct
    schema: iampolicyset/v1
    key: "iam/acct/{name}"
    params:
      name: { type: name, label: Policy set, max_len: 8 }
    quorum: { threshold: 2, role: OPERATOR, min_humans: 1, max_per_operator: 1 }
    revoke: same
    attest: { k: 1 }         # how many executors must agree on a report
    mutable: false           # this prefix's policies are sealed at the ceremony
    immutable_ack: true
  • type is a name. Neither the contract nor the library dispatches on it. The schema checks its shape; your executor gives it meaning.
  • role is the executor role. Every intent type on the connector compiles to a (prefix, SET_STATUS) policy on that role, with attest.k as the threshold. Roles are global in the contract, so a role shared between connectors could report on every namespace. Give each connector its own.
  • executors are signers. They hold the role and nothing else. The lints refuse an executor that also sits in an approving quorum.
  • An intent type names the connector, a prefix, a schema id for the document, a key template, the request parameters, the approving quorum, the revoke quorum and attest.k. bitcd.yaml has every field.

The reconcile step

@bitcd/core/reconcile is the part every executor shares. It does no I/O of its own; everything comes in through an ops object.

getValue()            → { digest, schema_ref, storage_class, version, tombstoned }
getStatus()           → { observed_revision, observed_digest, condition, status_version, exists }
getSpecBlob()         → string            // the desired document, from the store
getLiveBlob()         → string            // re-read your system; throw if nothing is there
applyToProvider(text) → void              // called only with a verified document
setStatus({ observed_revision, applied_hash, condition, reason })
digestOf(text)        → "0x…"

reconcileOnce(ops) runs one pass:

  1. Reads the value. A tombstone returns TOMBSTONED and touches nothing.
  2. Fetches the document and re-hashes it. Missing or mismatched, it attests FAILED with spec-missing or spec-tamper and stops.
  3. Calls applyToProvider. A throw is attested FAILED with apply-error.
  4. Calls getLiveBlob, hashes the result and attests SYNCED with that hash at the desired version.

driftCheckOnce(ops) is step 4 alone. Whether the attested hash equals the desired digest is the reader’s call: deriveCondition answers SYNCED, FAILED or OUTOFSYNC. An executor never attests OUTOFSYNC; drift is derived. A failed apply never changes desired state; a rollback is a new governed revision.

makeChainOps builds ops from a read-only contract handle, the executor’s contract handle, the provider, the value key, a store and the document’s key in it. Pass getLiveBlob and applyToProvider for a real system. Without them the live side is an object in the store, which is what the proof rig uses. renderConfig gives canonical JSON, so whoever proposes a document and whoever applies it hash the same bytes. The reference lists every export.

Build one

1. Declare it

Add a connector and an intent type to your organization file, as above, with a role of its own. Check it offline:

$ node core/bin/bitcd.mjs validate bitcd.yaml

2. Define the document

JSON, rendered with renderConfig. Write a project(live) that reduces what your system returns to the same shape, dropping server-added fields, so the live hash is stable. Decide what the connector may ever touch, the delegated set, and derive it from the verified manifest rather than from local configuration.

3. Write the provider

import { renderConfig } from "@bitcd/core/reconcile";

export function makeProvider(client, delegated) {
  return {
    getLiveBlob: async () => renderConfig(project(await client.read())),   // throws when nothing is there
    applyToProvider: async (text) => {
      const spec = JSON.parse(text);
      assertWithin(spec, delegated);          // refuse before touching anything
      await client.write(spec);
    },
    revoke: async () => client.remove(),
  };
}

4. Wire the step

import { makeChainOps, reconcileOnce, driftCheckOnce } from "@bitcd/core/reconcile";
import { provider, account, bitcdContract, loadDeployment, str } from "@bitcd/core/client";
import { s3Store } from "@bitcd/core/store";

const p     = provider();                                  // BITCD_RPC
const dep   = loadDeployment();                            // BITCD_DEPLOYMENT_FILE
const view  = bitcdContract(dep.address, p);
const exec  = bitcdContract(dep.address, account({ address: EXEC_ADDRESS, pk: EXEC_PK }, p));
const store = s3Store();                                   // BITCD_S3_ENDPOINT, BITCD_S3_BUCKET
const { getLiveBlob, applyToProvider, revoke } = makeProvider(client, delegated);

const ops = makeChainOps({
  view, writer: exec, provider: p, store,
  valueKey: str("iam/acct/pset"),
  specKey: "iam/acct/spec",       // where the rig's reconcile script puts the document
  getLiveBlob, applyToProvider, log: console.log,
});

5. Gate on the manifest

Before any pass, load the organization’s definition by verify-on-read. HALT means apply nothing; a transient store error gets the grace you set, a mismatch gets none.

import { loadOrgManifest, makeGate } from "@bitcd/core/org/loader";
import { DEFAULT_MANIFEST_KEY, manifestSpecKey } from "@bitcd/core/org/manifest";

const gate = makeGate({
  load: () => loadOrgManifest({
    getValue: () => view.get_value(str(DEFAULT_MANIFEST_KEY)),
    getBlob:  () => store.getText(manifestSpecKey(DEFAULT_MANIFEST_KEY)),
    valueKeyStr: DEFAULT_MANIFEST_KEY,
  }),
  graceMs: 30_000,
});
const g = await gate.check();      // { action: "RUN" | "HOLD" | "HALT", manifest?, reason? }

The manifest it returns carries the connectors and intent types as compiled. Take your prefixes and the delegated set from there.

6. Run the loop

The library has no daemon. Yours decides when to run the step: on a timer, or when the value’s version changes. This is the pass the skill’s template runs, proven on the devnet. For several keys, fold the events with fetchAllEvents and projectKeys from @bitcd/core/events and keep the keys under your prefixes.

// one pass; `state` remembers what this process applied or revoked
export async function pass({ ops, gate, prov, log }, state) {
  const g = await gate.check();
  if (g.action !== "RUN") { log(`${g.action}: ${g.reason}`); return { action: g.action }; }
  const v = await ops.getValue();
  if (v.version === 0) return { outcome: "no value yet" };
  if (v.tombstoned) {
    if (state.revokedAt === v.version) return { outcome: "revoked" };
    await prov.revoke();
    await ops.setStatus({ observed_revision: v.version, applied_hash: "0x0", condition: "SYNCED", reason: "revoked" });
    state.revokedAt = v.version;
    return { outcome: "revoked" };
  }
  if (v.version !== state.applied) {
    const r = await reconcileOnce(ops);                 // fetch, verify, apply, re-read, attest
    if (r.condition === "SYNCED") state.applied = v.version;
    return { outcome: r.condition, reason: r.reason };
  }
  const d = await driftCheckOnce(ops);                  // re-read your system, attest what is there
  if (BigInt(d.observedDigest) !== BigInt(d.desiredDigest)) return { outcome: (await reconcileOnce(ops)).condition };   // drift: put it back
  return { outcome: "in sync" };
}
// `watch` runs pass() every few seconds; a failed apply is retried on the next pass

If your documents carry an expiry, compare it with the latest block’s timestamp, never the wall clock, then revoke and attest expired the same way. The skill below ships this loop as a runnable template, with a file-backed provider to replace and a script that proves the connector on the devnet.

7. Test it on the devnet

The proof rig’s organization already declares demo-iam on prefix iam/acct with exec1 as its executor, and e2e/scripts/reconcile.mjs runs the step over it through five checks, then exits. Point your executor at the same deployment:

$ export NO_PROXY="localhost,127.0.0.1,0.0.0.0,ministack"
$ docker compose up -d --wait devnet ministack   # the chain on :5051, the store on :4566
$ pnpm install
$ cd e2e && pnpm onboard                       # deploys e2e/bitcd.yaml; ends with diff CLEAN
$ pnpm reconcile                               # five checks: apply, drift, reconverge, new revision, failed apply

The keystore it writes is e2e/.onboard-tmp/keys.json; exec1 is your executor and e2e/deployment.json is the deployment record. Then put your executor through the same checks the script makes: a commit is applied and derives SYNCED; an edit behind its back derives OUTOFSYNC with desired unchanged; a new revision lags, then applies; a provider that throws is attested FAILED and desired is unchanged. Add one check the script does not make: a tombstone is revoked.

What must hold

  • The executor account holds its role and sits in no quorum.
  • It never writes desired state. Its governed write on chain is its own attestation.
  • applyToProvider refuses anything outside the delegated set before touching the system.
  • A missing or tampered document and a failing apply are attested FAILED; a live read that finds nothing attests hash zero. A report that reverts throws; never carry on as if it landed.
  • Expiry and lease checks run on block time.
  • Credentials for the managed system are scoped to what the connector manages.

With your agent

A skill in the repository carries a runnable connector template and walks a coding agent through the steps above against the local devnet. Give it this line:

Read https://raw.githubusercontent.com/bitcdorg/bitcd-core/main/skills/build-connector/SKILL.md and follow it.

It needs git, Docker, Node 22 and pnpm, and a description of the system you want governed.

Built on this model

Connectors that exist today, each a document schema, a provider and a loop over the same step:

ConnectorGoverned valueApply and revoke
terraformThe delegated input variables of one workspace, one assignment per workspace.Writes a var-file and runs terraform apply; in sync only when terraform plan is empty. Revoke returns every variable to its module default.
aws-iamThe delegated managed policies on one IAM user or role.Attaches and detaches inside the delegated set; a policy attached by hand is detached, nothing outside the set is touched. Revoke detaches.
k8s-rbacOne RoleBinding or ClusterRoleBinding, projected to its governed fields before hashing.Applies through the Kubernetes API, from a daemon or an in-cluster bridge that holds only the executor role. Revoke deletes the binding.
k8s-leaseA Kubernetes leader Lease as a bitcd lock.Planned failover is release and acquire; crash failover is TTL expiry; forced failover is a quorum-approved force-unlock.
terraform-backendTerraform’s http backend over the lock, with the state digest on chain.LOCK and UNLOCK are the lease; state is read back only when its digest matches.