bitcd

@bitcd/core

The JavaScript side of the contract: propose and approve governed changes, sign readable approvals, run executors that apply desired state and report, read documents from untrusted storage. The compiled contract ships inside it, so deploying and talking to bitcd needs no Cairo toolchain.

Using it. npm install @bitcd/core starknet. ESM only; every import goes through a subpath, there is no root export. starknet ^9 is a peer dependency; @aws-sdk/client-s3 is an optional one for the store. To work against an unreleased checkout, depend on it by path instead: "@bitcd/core": "link:../bitcd-core/core".

Two examples

You supply accounts, prefixes and document schemas (/fixtures holds optional demo data). Two excerpts; the capitalised names are values you provide.

An executor

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

const p     = provider();                        // BITCD_RPC, default http://localhost:5051
const dep   = loadDeployment(DEPLOYMENT_FILE);   // or set BITCD_DEPLOYMENT_FILE
const view  = bitcdContract(dep.address, p);     // read-only
const exec  = bitcdContract(dep.address, account(EXECUTOR_KEY, p));   // the executor key
const store = s3Store();                         // BITCD_S3_ENDPOINT / BITCD_S3_BUCKET

const ops = makeChainOps({
  view, writer: exec, provider: p, store,
  valueKey: str("app/flags/checkout"),
  specKey: "app/flags/checkout/spec",           // where the desired document lives
  getLiveBlob: readFromYourSystem,              // or liveKey, to use a stored copy
  applyToProvider: applyToYourSystem,
});
await reconcileOnce(ops);    // read, check the fingerprint, apply, report
await driftCheckOnce(ops);   // re-read the live system and report, without applying

An agent in a fleet

import { makeFleetPeer } from "@bitcd/core/fleet";
import { evidenceLog } from "@bitcd/core/evidence";

const peer = makeFleetPeer({
  contractAddress: dep.address,
  self: { address: ME.address, operator: "OP_FLEET_A" },
  prefixStr: "fleet/remediation",
  skill,          // your predicate — the shape is below
  fetchCtx,       // loads what the predicate may look at, e.g. via verifiedParams
  escalate: (_proposal, decision) => decision.evidence?.["blast-radius"]?.risk === "high",
  view, writer: bitcdContract(dep.address, account(ME, p)), provider: p, store,
  evidence: evidenceLog("./evidence/fleet-a.jsonl"),
  log: console.log,
});
// prefixStr scopes commits and waiting; which proposals it votes on is skill.appliesTo's call
await peer.pass();                   // one round: read proposals, decide, vote, escalate, commit
peer.watch({ intervalMs: 5000 });    // or keep running

/protocol

The values the contract compares against. Every caller encodes them the same way.

ExportWhat
str(s)Encode a string as a Cairo short-string felt. The encoder rejects non-ASCII or overlong strings; assertShortString gives a clearer error first.
assertShortString(s, what = "key") → sThrows unless s is a string of 1–SHORTSTRING_MAX (31) characters (it doesn't check for ASCII). Keys, prefixes, roles and schema tags are all one felt, so keep them to 31 ASCII characters.
digestOf(text)The fingerprint the contract stores and readers recompute (starknetKeccak), as "0x…".
ACTIONThe seven policy tags: ACQUIRE, SET_VALUE, TOMBSTONE_VALUE, SET_STATUS, FORCE_UNLOCK, SET_POLICY, SET_ROLE. All of them key policies; commit carries out the five governed ones — ACQUIRE and SET_STATUS have their own entry points.
CONDITION_FELTSYNCED, OUTOFSYNC, FAILED, PROGRESSING as felts, for set_status. The string versions are CONDITION in /reconcile.
GLOBALThe reserved prefix where signer management lives.
STORAGE_CLASS{ S3 } — the storage hint stored beside a digest.

/client

Connecting to a node and the contract, and relaying signed approvals.

ExportWhat
provider(nodeUrl = RPC_URL())A starknet RpcProvider. RPC_URL() reads BITCD_RPC, defaulting to http://localhost:5051.
account({address, pk}, provider?)A starknet Account from a key spec.
bitcdContract(addressOrRecord, conn, {abi}?)A contract handle with the bitcd ABI — from /artifacts (see its resolution order below) unless you pass abi. Pass a provider for reads, an account for writes.
loadDeployment(file = BITCD_DEPLOYMENT_FILE)Reads a deployment record {address, classHash, owner, rpc, block} — block is the block the contract was deployed in, which readers scan events from. Throws if no path is given or set.
approveWithSigs({contractAddress, proposalId, action, prefix, params, signers, relayer?, p?, abi?})For each of signers ([{address, pk}]) it checks the message hash against the contract's approval_digest before signing, then submits all signatures in one approve_sigs transaction — the contract verifies the signatures themselves. At most 64 per call. Returns the transaction hash. The relayer ({address, pk}) pays; without one, the first signer submits — pass a separate relayer if people shouldn't send transactions themselves.
applyNoProxy(hosts = "localhost,127.0.0.1,0.0.0.0")Appends the hosts to NO_PROXY and sets no_proxy to the same value. Only when you call it.
strRe-exported from /protocol.

/snip12

The typed message behind every human approval. Its hash must match the contract's approval_digest; assertRoundTrip checks that. action and prefix are passed as felts (str(…)), chainId as the chain's id felt.

ExportWhat
paramsHash(params)The tagged, length-prefixed Poseidon hash of a proposal's parameters, matching the contract.
approvalTypedData({verifyingContract, chainId, proposalId, action, prefix, params})The SNIP-12 document a wallet shows and signs: contract, proposal, action, prefix, and the parameters' hash.
approvalMessageHash(args, approver)The hash one approver signs; it must equal approval_digest(proposalId, approver).
signApproval(account, args) → {approver, signature}Signs with the account (signMessage) and returns what approve_sigs takes. It doesn't check the hash against the chain — call assertRoundTrip first, or use approveWithSigs, which does both.
assertRoundTrip(view, args, approver)Throws unless the local hash equals the contract's approval_digest. Run it before signing, so a tampered parameter file is caught before a signature exists.

/reconcile

One executor pass: read what is wanted, check it, apply it, report. Everything it touches comes through an ops object, so the same code runs in tests, in daemons and inside a cluster:

// what reconcileOnce expects from ops
getValue()            → { digest, schema_ref, storage_class, version, tombstoned }
getStatus()           → { observed_revision, observed_digest, condition, … }
getSpecBlob()         → string   // the desired document
getLiveBlob()         → string   // what the live system holds, rendered the same way
applyToProvider(text) → void     // called only with a verified document
setStatus({ observed_revision, applied_hash, condition, reason })
digestOf(text)        → "0x…"
ExportWhat
reconcileOnce(ops, {failApply?, reportFailure?})One full pass. After applying, it re-reads the live system and reports SYNCED with its fingerprint, returning {condition, observedDigest, desiredDigest, present, version}; if that read fails, it reports observedDigest: "0x0" with present: false. A document that's missing or doesn't match its fingerprint is reported FAILED (spec-missing / spec-tamper) and never applied: {condition, reason, version}. If applyToProvider throws, the pass reports FAILED (apply-error) and returns the error's message as error. reportFailure: false skips the FAILED report on every failure path — for a caller that tracks what it has already reported, as the shipped executor does once per revision — and the result then carries reported: false. A deleted value returns {condition: "TOMBSTONED", version}. failApply simulates a failed apply.
driftCheckOnce(ops)Re-reads the live system and reports it, without applying, with the same report and return shape as a successful reconcileOnce (a deleted value returns {condition: "TOMBSTONED", version}). Whether the live fingerprint is drift is for readers to decide by comparing it with the desired one.
deriveCondition({desiredVersion, desiredDigest, observedRevision, observedDigest, claimed})What a reader should conclude, in this order: SYNCED when the report has caught up to the current version with the desired fingerprint; otherwise FAILED if the executor reported a failure; otherwise OUTOFSYNC.
renderConfig(config)Canonical JSON (keys sorted recursively, no whitespace), so whoever proposes a document and whoever applies it produce the same bytes.
makeChainOps({view, writer, provider, valueKey, store, specKey, liveKey?, getLiveBlob?, applyToProvider?, log?})Builds ops from contract handles and a store. By default the live side reads and writes liveKey in the store; pass getLiveBlob and applyToProvider to work against a real system instead.
CONDITIONThe condition names as strings.

/fleet

A voting agent is a signer whose vote is a predicate: a deterministic check over the proposal's verified contents, never a judgment call and never a model. A skill:

{ name, role, operatorId, address,
  appliesTo(proposal) → boolean,
  check(proposal, ctx) → { pass, reason, evidence } }
ExportWhat
VOTEAPPROVE, WITHHOLD (the check ran and failed — recorded as evidence) and ABSTAIN (not for me, or not eligible — nothing recorded).
decide(proposal, {getSigner, fetchCtx, skill}) → {vote, reason?, evidence?}The decision on its own, with no IO beyond what you inject — easy to unit-test.
composePredicates(predicates)Combines [{name, check}] into one check. It stops at the first failure and returns {pass: false, reason: "<name>: <reason>", evidence}, which decide turns into a WITHHOLD. Evidence from the checks that ran is collected by name.
proposalParamsKey(prefix, id)<prefix>/proposals/<id>.json — where a proposer publishes the parameters ({params}), since the contract keeps them but has no getter.
verifiedParams({view, store, chainId, contractAddress, proposal, approver}) → paramsReads the published parameters and proves them against approval_digest. A tampered file throws, so a predicate never sees unverified input.
makeVoterOps({view, writer, provider})Chain access for one agent: getSigner, approve, requestHumanReview.
voteOnce({provider, address, self, ops, skill, fetchCtx, evidence?, log?, seen?, escalate?, proposals?, held?}) → [{id, vote, reason, escalated}]One round for one agent (self is its address): read the proposals (or use proposals, already folded), decide the ones it hasn't voted on, approve, escalate if escalate(proposal, decision) says so, and record each non-abstain decision once its transactions succeed. It escalates before approving, and a proposal counts as decided only after its transactions land: a failed vote is tried again next round and comes back with failed: true, and an agent that approved but whose escalation didn't land asks for it again. seen remembers decided proposals across rounds — pass the same Set every time, or withheld decisions are recorded again. held, another Set you keep, gains a proposal the moment its vote fails and loses it once the vote lands or the chain shows it — don't relay commits for what it holds.
contractReason(message)The quoted revert reason in an error message ('proposal: executed'), or null when it finds none — the heuristic the daemon uses to tell a contract refusal from a transient failure.
paramsPublished(store)A check that a proposal's parameters have been published — the default for isDecidable below.
makeFleetPeer({contractAddress, self, prefixStr, skill, fetchCtx, escalate?, view, writer, provider, store, evidence?, log?, hooks?, isDecidable?, paramsGraceMs = 60000, now?}) → {pass, watch, status}A complete agent daemon: each round reads proposals, votes, escalates, relays commits and records evidence. pass() returns that round's decisions, watch() loops until the process ends, and status() lists this prefix's proposals with their rosters as arrays. prefixStr scopes commits, waiting and status; which proposals get votes is up to skill.appliesTo, which sees proposals with action and prefix decoded to strings. self is {address, operator}; hooks are {beforePass, afterPass(proposals)}; watch({intervalMs = 5000}) loops. A proposal whose parameters aren't published yet is skipped for up to paramsGraceMs rather than decided, because a decision is final for the process. It doesn't relay a commit for a proposal whose vote or escalation failed that round. A commit the contract refuses for a reason other than policy unmet isn't tried again; one that fails without a contract reason (a timeout, a dropped connection) is.

/evidence

The contract records that an agent voted, not why. Agents write their reasons to an append-only JSONL file. Each line carries prev, the fingerprint of the line before it, and a sequence number, so an edit, deletion or reordering breaks the chain from that point on.

ExportWhat
chainLine(prevLineText, seq, ts, record)Renders one line: {seq, prev, ts, …record}. Pure — you supply the time. Don't use seq, prev or ts as record fields.
verifyChain(text){ok: true, length}, or {ok: false, length, badLine, reason} pointing at the first line that breaks.
evidenceLog(path, {now}?) → {path, append, verify}A file-backed log. append(record) reads the file's last line, writes the next one, and returns it.
GENESIS_PREV"0x0", the prev of the first line.

Appends take turns through a lock file beside the log (<path>.lock), so two processes cannot reuse a sequence number. A lock is taken over only when its holder no longer runs on this machine. A waiting writer gives up with an error after five seconds rather than write unguarded. The chain protects only the history it links: cutting lines off the end, or rewriting the whole file and recomputing it, still verifies. Keep checkpoints of recent entries somewhere the agent cannot reach.

/store

s3Store({endpoint, bucket, region, credentials, forcePathStyle}) returns {client, bucket, ensureBucket, getText(key), putText(key, body), deleteObject(key)}. resolveStoreConfig(opts) shows what it will use without connecting:

SettingWhere it comes from
endpointBITCD_S3_ENDPOINT, else the local store at http://localhost:4566. An empty value (or endpoint: null) means AWS's own regional endpoint.
credentialsThe credentials option, else AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY, with AWS_SESSION_TOKEN for temporary credentials. Without either: the throwaway local pair for a local endpoint — a local or container hostname, or any plain-http URL — and the AWS SDK's own chain (profiles, SSO, instance and task roles) for anything else.
bucket · regionBITCD_S3_BUCKET (else bitcd-tfstate) · BITCD_S3_REGION (else us-east-1).
addressingPath-style for a custom endpoint, virtual-hosted for AWS.

ensureBucket() creates the bucket only when HeadBucket reports it missing, so credentials limited to objects work with an existing bucket. digestOf is re-exported here.

The store checks nothing. It moves bytes in and out of storage you do not have to trust. The reader checks: reconcileOnce refuses a document that does not match the chain, verifiedParams proves parameters, and anything else you load is compared with digestOf before you act on it.

/events

The chain is not built for queries. Dashboards and history come from the event stream. The indexer and other readers use these functions.

ExportWhat
SELECTORS, decode(felt)Selectors for the events these folds use — ValueChanged, StatusChanged, StatusAttested and the five proposal events — and a short-string decoder.
fetchAllEvents(provider, address, {fromBlock?})Pages through those events from the contract's first block — fromBlock when given (a deployment record's block), else located by deploymentBlock — never from genesis, which public nodes refuse to scan. Other events (locks, roles, policies) aren't included.
deploymentBlock(provider, address, {window?})The block the contract was deployed in: the ownership event its constructor emits, searched newest first in windows of a million blocks, found once per process and kept. 0 when there is none to find or the node can't answer.
projectKeys(events, {labelKey}?) → rowsOne row per value: {valueKey, prefix, desired, observed, votes, state, agreement}, plus kind when you pass labelKey(prefix). desired is {digest, schema_ref, storage_class, version}; observed is {revision, digest, claimed, status_version, executor}; votes maps each executor to its latest {revision, hash, condition}. state is DISAGREE, SYNCED, FAILED, OUTOFSYNC or NOSTATUS; agreement ({voters, distinct, lead}, lead being {hash, count} or null) tallies executor reports at the current version — a summary, not the contract's eligibility-checked decision.
projectProposals(events) → rowsOne row per proposal, by id: {id, action, prefix, proposer, approvers, intentSigned, escalatedBy, committed}. The three rosters are Sets of addresses; intentSigned holds the signed approvals, the only ones that can count as human.

/ceremony and /artifacts

Deploying the contract and seeding it.

ExportWhat
deployBitcd({owner, deploymentFile?, rpc?, sierra?, casm?, log?}) → recordDeclares and deploys with owner (an Account) as the temporary owner, and returns {address, classHash, owner, rpc, block} — also written to deploymentFile if given. Uses /artifacts (below) unless you pass sierra/casm, and, through the log callback you pass (say console.log), says which class it deployed from and warns when its hash isn't the bundled one.
signerSpec({is_human, operator_id, role}) · policySpec(threshold, role, min_humans, max_per_operator, allow_open)The structs set_signer and set_policy take; they add active: true and exists: true.
runCeremony({contract, provider, signers = [], policies = [], renounce = false, log?})Seeds signers ([{label?, address, signer}]) and policies ([{label?, prefix, action, policy}]) in order through the owner, then renounces if asked. It doesn't read the seed back — do that yourself before renouncing anything that matters.
sierra() · casm() · abi() · manifest() · artifactSource()The compiled contract. Each file comes from BITCD_SIERRA / BITCD_CASM if set, else a local contracts/target/dev build if one exists, else the bundled copy; artifactSource() says where the Sierra file comes from ("env", "local-build" or "bundled"); CASM resolves separately. abi() comes from whichever Sierra file was picked. manifest() always describes the bundled copy ({classHash, gitCommit, …}, or null if absent); the repository's CI checks the bundled copy against a fresh build, and deployBitcd warns when what it deployed isn't that class.

/fixtures is also exported: SCHEMA_REF and sampleConfig({serial = 1, allowDelete = false}), the sample document the demos share. Demo data, no stability promise.

/org, /pending and /cli

What the bitcd CLI is built from: checking a bitcd.yaml, compiling it, and loading the governed definition (orgmanifest/v1) it publishes. An executor loads the definition through /org/loader.

SubpathExportWhat
/org/schemavalidateOrg(doc) → {ok, errors, model}Checks a parsed bitcd/v1 document. Never throws; model, present only when ok, is what the next two take.
/org/invariantscheckInvariants(model) → messagesThe checks that stop an unsafe setup: one message per finding, starting with its code in brackets. An empty list means none.
/org/compilecompileOrg(model, {classHash?}) → {ceremony, manifest, executorConfigs}The signers and policies to seed, the definition to publish, and one configuration per connector. The same model always compiles to the same bytes.
/org/manifestvalidateOrgManifest(m) · canonicalOrgManifest(m) · manifestSpecKey(key)orgmanifest/v1: validation ({ok, errors}), the canonical text whose fingerprint the quorum commits (it throws on an invalid manifest), and the store key of the definition's document, <key>/spec.
/org/loaderloadOrgManifest({getValue, getBlob, valueKeyStr, digestOf?}) · makeGate({load, graceMs = 0})loadOrgManifest reads the definition through the two functions you pass and checks it against the chain. It never throws: it returns {ok: true, manifest, version, digest}, or {ok: false, class, reason} where class is "transient" (chain or store unreachable) or "terminal" (missing, tombstoned, wrong schema, a document that doesn't match, an invalid manifest). makeGate(…).check() turns that into an action: RUN with a verified manifest (stale: true when the grace window covers a transient failure), HOLD for a transient failure before anything was verified, and HALT for any terminal failure or a transient one past the grace window. Stopping governed work on HALT is the host's job.
/org/keysexpandKey(template, params) · templateFields(template) · specKeyFor(key)Fills a key template such as k8s/rbac/{ns}/{name} — throwing on a missing or unsafe parameter, or a result over 31 characters — lists the template's fields in order, and names the store key of a value's document, <key>/spec.
/org/lint · /org/approverslintSecrets(value, where, errors, allowFields?) · pickApprovers(pool, quorum)The check for secret-shaped field names and values that the schema and the manifest share, and the choice of approvers from a keystore that the CLI's development flows use (null when the keystore can't meet the quorum).
/pendingpendingBlobKey(prefix, id) · pendingParamsKey(prefix, id) · publishPending(store, prefix, id, blob, params) · locateCommitted({…}) · promoteCommitted({…})Where a proposed document waits until its commit: <prefix>/proposals/<id>.blob, with its parameters in .json beside it. locateCommitted finds the bytes that match the committed fingerprint — the live document or a proposal's pending one — without writing; promoteCommitted copies them into place. Neither uses a document that doesn't match.
/clirunBitcd({argv?, parseYaml, verbs?})The bitcd command as a function. parseYaml is required — the entry point, core/bin/bitcd.mjs, passes the yaml package's parse. verbs adds a host's own commands beside the built-in ones, as {"grant": {flags, booleans, run}}.

Things to know

  • Keys fit in one felt. 31 ASCII characters for every lock key, value key, prefix and schema tag. The DSL uses names as they are; a longer name needs a shorter key of your choosing.
  • The bundled contract can fall behind a local rebuild. pnpm sync-artifacts in bitcd-core refreshes it; CI checks the class hash.
  • The SNIP-12 domain (bitcd, revision 1) is part of the protocol. It is built into the contract's approval_digest and cannot change without a new deployment.
  • An agent's holds live in its process. makeFleetPeer does not relay a commit while its own vote or escalation is pending. A restarted daemon holds nothing, and anyone may commit a proposal whose quorum is met. What guarantees a human is an escalation that has landed on chain.
  • Accounts are yours. The library ships no keys. Development accounts live in the harnesses, production ones in your own reviewed files.