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
typeis a name. Neither the contract nor the library dispatches on it. The schema checks its shape; your executor gives it meaning.roleis the executor role. Every intent type on the connector compiles to a(prefix, SET_STATUS)policy on that role, withattest.kas the threshold. Roles are global in the contract, so a role shared between connectors could report on every namespace. Give each connector its own.executorsare 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:
- Reads the value. A tombstone returns
TOMBSTONEDand touches nothing. - Fetches the document and re-hashes it. Missing or mismatched, it attests
FAILEDwithspec-missingorspec-tamperand stops. - Calls
applyToProvider. A throw is attestedFAILEDwithapply-error. - Calls
getLiveBlob, hashes the result and attestsSYNCEDwith 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.yaml2. 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 passIf 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 applyThe 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.
applyToProviderrefuses 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:
| Connector | Governed value | Apply and revoke |
|---|---|---|
| terraform | The 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-iam | The 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-rbac | One 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-lease | A 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-backend | Terraform’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. |