Registry

Every bucket key scafo uses, the JSON each holds, and how it is written.

There is no database and no consensus protocol. The registry is a set of JSON documents in a bucket, written with conditional PUTs. Every record carries v: 1; readers must reject unknown versions.

All registry documents live under the root prefix _scafo, itself under the configured bucket.prefix when one is set. The one exception is the Celld fleet state, which lives outside _scafo.

#Key layout

RecordKeyId rules
Registry meta_scafo/meta.json—
Deploy freeze_scafo/freeze.json—
Change hint_scafo/changes.json—
Coordinator lease_scafo/coordinator/lease.json—
Store conformance probe_scafo/probe/<token>.jsontoken is a content id
Node_scafo/nodes/<id>.jsonnode id
Trusted operator key_scafo/operators/<id>.jsonoperator id
Identity reservation_scafo/identities/<id>.jsonnode id charset; one shared namespace for nodes and operators
Placement_scafo/placement/<name>.jsonapplication name
Observation flush_scafo/observe/<node>/<yyyy>/<mm>/<dd>/<hh>/<epochMs>-<seq>.<ext>node id; seq padded to 6 digits; ext 1–16 lowercase alphanumerics
Domain claim_scafo/domains/<host>.jsonvalidated host
Application_scafo/applications/<name>.jsonapplication name
Trust policy_scafo/trust/<name>.jsonapplication name
Deployment_scafo/deployments/<name>/<version>.jsonapplication name; version a content id
Deploy_scafo/deploys/<name>/<id>.jsonapplication name; id is <name>.<16 lowercase hex>
TLS object_scafo/tls/objects/<caller key>caller-owned key, see below
TLS lock_scafo/tls/locks/<name>same rules as a TLS key
Celld fleet statefleets/<name>application name; outside _scafo

The layout is flat and kind-prefixed: one prefix per kind of record, so listing one kind never reads another kind's keys. That is what keeps _scafo/applications/ at exactly one key per application — the listing a node runs whenever the change hint has moved — where a layout filing an application's history beneath its own name would make each of those listings read every deployment and every deploy of the whole installation just to learn the names.

Listing prefixes used by the daemon: _scafo/nodes/, _scafo/operators/, _scafo/identities/, _scafo/placement/, _scafo/observe/, _scafo/domains/, _scafo/applications/, _scafo/deployments/<name>/, _scafo/deploys/, _scafo/deploys/<name>/, _scafo/tls/objects/, _scafo/tls/locks/.

_scafo/deploys/ is listed at its root by the coordinator's reaper: one listing for the whole installation per sweep, rather than one per application.

Every per-application prefix is listed with its trailing separator, so a listing under shop never reaches shop-preview or shop2.

A name under _scafo/applications/ that is not an application name, and a name under a deploys prefix that is not a deploy id, are both skipped rather than read: they name no record of scafo's.

#Id rules

KindPatternFailure
Content id (deployment versions, probe tokens)^[a-f\d]{8,64}$ — 8–64 lowercase hexadecimal charactersinvalid-slug, Invalid content id
Node id^[a-z\d_-]{1,128}$ — 1–128 lowercase alphanumerics, hyphens or underscores. Node ids double as celld session ids, which is why underscores are allowedinvalid-slug, Invalid node id
Operator idSame charset as a node id, to preserve the shared identity spaceinvalid-slug, Invalid operator id
Application name^[a-z\d]+(?:-[a-z\d]+)*$, at most 63 characters — lowercase alphanumeric runs joined by single hyphens, no leading, trailing or doubled hyphen. The same rule Celld applies to a config's name, and bounded by the DNS label limitinvalid-slug, Invalid slug
Deploy idAn application name, a dot, and 16 lowercase hexadecimal characters. A name carries no dot, so the first dot is the separatorinvalid-slug, Invalid deploy id
Operator scope entry* alone, an exact application name, or a prefix glob: a run of name characters, which may end in a hyphen, followed by one trailing *. shop-* reaches shop-preview and not shopinvalid-input, Invalid scope entry
TLS key or lock nameRelative (no leading /), printable ASCII only, no empty, . or .. segmentinvalid-input, Invalid storage key

Warning

fleets/<name> carries no trailing separator, so a listing under it must append one: fleets/shop would otherwise match fleets/shop2/....

#Record shapes

#RegistryMeta — _scafo/meta.json

FieldTypeNotes
v1
platformDomainsstring[]Apex domains the platform serves wildcard subdomains under
createdAtISO string

Written at bootstrap by the first node to arrive, which also proves the bucket honours conditional writes and ranged reads. A meta document announcing another version is store-nonconforming.

#FreezeRecord — _scafo/freeze.json

The fleet's deploy freeze. Its absence is what "this fleet takes deploys" means; there is no unfrozen record.

FieldTypeNotes
v1
frozenAtISO string
bystringOperator id of the admin who froze deploys, as their token was verified — never anything a request body said
reasonstring?Why, when the admin gave one. Trimmed, one line, at most 200 characters; a longer one or one carrying a newline or control character is invalid-input

Written by one conditional create, so freezing twice is one freeze: a second scafo fleet freeze finds the record already there and neither the stamp nor the reason moves. Lifting it is one unconditional delete.

While it stands, every node refuses deploy intake with deploys-frozen (409) before anything is created, and the refusal names when, by whom, why and that scafo fleet unfreeze lifts it. Nothing else changes: serving, reconciliation, retirement and scafo app delete all carry on.

#ChangeHint — _scafo/changes.json

One object that says the registry moved since you last looked, so an idle installation costs one GET a pass instead of a listing and a read of every record it holds.

FieldTypeNotes
v1
tokenstring16 lowercase hexadecimal characters. Any value but the last one a reader saw means "look again"

Written blind — no precondition, last writer wins — after a successful write under _scafo/applications/, _scafo/placement/ or _scafo/domains/, by a decorator every registry context is built with. So a bucket-direct scafo app retire run from an operator's laptop hints exactly as a deploy through a node does, fifty concurrent deploys never contend on it, and a write that failed hints nothing.

Node records are deliberately not hinted: a heartbeat rewrites one every 30 seconds, and a token minted for each would cost every node a listing several times a minute. A node that arrives, starts draining, or changes what it serves mints a token explicitly, because the fleet's routing is rendered from that.

It is a hint and never authority:

SituationConsequence
The hint could not be writtenOne line in the log. The mutation itself happened, and other nodes see it on their next full resync
The hint is absent, empty or unreadableRead as "no token", which falls back to the resync timer — the same behaviour as a lost hint
The store could not be reachedRaised. That is a registry read failing, and the caller has to know

#CoordinatorLease — _scafo/coordinator/lease.json

FieldTypeNotes
v1
nodestringId of the node holding the lease
acquiredAtISO string
ttlMsnumberLifetime this acquisition claims, from coordinator.leaseTtlMs (45 s by default). A release writes 0, so the next node takes it at once
fencenumberMonotonically increasing fencing token; stale holders are rejected by it

The holder renews at a third of ttlMs, which is also the coordinator loop's tick.

#NodeRecord — _scafo/nodes/<id>.json

FieldTypeNotes
v1
idstring
publicKey{ kty: "OKP", crv: "Ed25519", x: string }Verifies the node's signatures
addresses{ internal: string, apiPort: number }
heartbeatAtISO string?Last time the daemon said it is still there. This is the liveness signal placement decides from
drainingAtISO string?Graceful-shutdown marker. A draining node is ineligible despite a heartbeat
wireguardobject?{ publicKey, endpoint, address } — X25519 public key as wg pubkey prints it, host:port peers send tunnel traffic to, and the node's own tunnel address with its prefix length
labelsRecord<string,string>?Selector labels, replaced from boot configuration at registration
versions{ scafo: string, celld: string }?The binaries the daemon booted with, written at registration on every boot and never by the heartbeat. Absent on a record written by a build that predates the field
servingRecord<"<application name>", { port: number }>?Every application this node runs a process for, and the port that process takes public traffic on, refreshed with the heartbeat. Absent means nothing — a node that has not said what it serves is not routed to

Registration guards: an id belonging to an operator key or to another identity is reference-held; a record with a different public key is reference-held; changing a node's id is immutable.

#IdentityReservation — _scafo/identities/<id>.json

FieldTypeNotes
v1
kind"node" | "operator"
publicKeyEd25519 JWK

Durable first-claim record. Deleting a node or an operator deliberately leaves this behind: an id is a public-key identity, not a reusable name.

#OperatorRecord — _scafo/operators/<id>.json

FieldTypeNotes
v1
idstring
publicKeyEd25519 JWKEvery token this operator signs is verified against it
role"admin" | "operator"?Omitted legacy roles read as admin; new records always set it
supersedesstring?Key this one was registered to replace. It is what authorizes the contract half of a rotation
applicationsstring[]?Application scope: *, exact names, or prefix globs such as shop-*. Missing means no applications for an operator and every one for an admin; ["*"] must stand alone

#ApplicationRecord — _scafo/applications/<name>.json

The unit scafo deploys, places, serves, blocks, retires and deletes.

FieldTypeNotes
v1
namestringThe application name; also the key segment
createdAtISO string
blockedtrue?Stops deploys, runtime, routing and certificates without deleting anything. Absence is unblocked
labelsRecord<string,string>?Fleet-owned metadata, replaced wholesale, never interpreted, and stripped from a non-admin's view
deployment{ revision: number, deployedAt: string } | nullMonotonic deployment notice; null until the first successful deploy. Never names a version
domainsstring[]Custom domains claimed for it
wildcardDomainsstring[]?The subset of domains claimed with the single label below them, in the same order. Mirrored here so the routing render — which reads application records and nothing else on every pass — knows which host is a wildcard without reading a claim record. The key is dropped outright when nothing is in it
replicasnumber | "all"?Omitted resolves to every live node that may serve it
nodeSelectorRecord<string,string>?Exact-match AND selector; a node needs every listed pair
retiredAtISO string?Stops serving; becomes deletable after its grace period

v and name are the identity and never change; an update that alters either is immutable. Everything else is mutable state, and every mutation is a functional CAS update that preserves the fields it does not own — so a deploy can never drop blocked or labels.

There is no create command and no create route: the first deploy creates the application, a retried create returns the existing record unchanged, and every other setter refuses a missing one with not-found.

#PlacementRecord — _scafo/placement/<name>.json

FieldTypeNotes
v1
nodesstring[]Nodes that should run this application, lexicographically sorted
decidedAtISO string
fencenumberLease fence; stale coordinators cannot overwrite a newer decision

Absence means no process may start. A write under a stale fence is lease-lost.

#DeploymentRecord — _scafo/deployments/<name>/<version>.json

One record per thing Celld serves, filed under the version Celld assigned and nothing else.

FieldTypeNotes
v1
versionstringThe Celld version this deployment is published as; it is also the key segment
source{ provider, repo, ref, commit }?Self-asserted source metadata stated by the caller that uploaded it
oidc{ issuer, subject }?Present only when a verified workload token uploaded this deployment
createdAtISO stringStamped by the registry

Immutable: created, read and listed, never updated. Recording the same deployment again is idempotent as long as every field but createdAt matches; otherwise immutable, Deployment already exists. Deletion is the reaper's or scafo deployment prune's.

#DeployRecord — _scafo/deploys/<name>/<id>.json

One deploy, from the moment a node accepted it to the moment it stopped moving. The state lives here rather than in the accepting node's memory, so any node of the installation answers a poll and a node restarting mid-deploy leaves an answer rather than a forgotten id.

FieldTypeNotes
v1
idstring<name>.<16 hex>; the application half is what authenticates a poll
applicationstring
stateaccepted | uploading | deployed | failed
nodestringNode that accepted the deploy and is carrying it out
acceptedAtISO string
updatedAtISO stringWhen the state last moved; the reaper reads it to spot an abandoned deploy
deploymentstring?Deployment the deploy produced, by version; absent until one has been recorded
codeerror code?The code of the error that failed it, when it was a typed one
detailstring?Why it failed, when it did
source{ provider, repo, ref, commit }?What the caller said it was deploying
oidc{ issuer, subject }?The workload identity verified at intake

deployed and failed are terminal: a deploy that reached either never moves again, and a later write to it is cas-conflict, Deploy has already finished.

TransitionWritten by
Create, as acceptedThe node that accepted the deploy, before the id is answered — a handle nothing is recorded under is one no node could answer for. An id already taken is immutable
uploading, deployedThe same node, as its publish proceeds
failedThe same node, carrying the code of the error that failed it
failed with deploy-timeoutThe coordinator's reaper, for a non-terminal record that has not moved for a whole liveness window (90 s) and whose node is no longer answering
DeletedThe reaper, once a terminal record's updatedAt is 7 days old. scafo app delete also deletes every deploy record of that application

GET /v1/deploys/:id reaches the deploy-timeout judgement itself, at read time, on exactly the same three conditions, and answers failed without writing anything — so nobody waits for a sweep to hear that a deploy's node is gone. The sweep is what stores that answer; the two never disagree about the outcome, only about when it is recorded.

A poll for an id whose record is gone is not-found, and the detail says the record is kept for seven days after the deploy finishes.

#TrustPolicy — _scafo/trust/<name>.json

FieldTypeNotes
v1
issuersarrayEach { iss, aud, subjects: string[] }

subjects are patterns where * matches any run of characters. One policy belongs to one application, exactly, and it may exist before the application does: an admin pre-grants a name, and the first deploy creates the application under it.

#DomainClaim — _scafo/domains/<host>.json

FieldTypeNotes
v1
applicationstringThe application the host is claimed for
claimedAtISO string
wildcardtrue?Set when the claim covers the single label below the host as well as the host itself. Absent is an exact claim, which is every claim written before the flag existed: the field is additive and the record stays v: 1

The key is the host, lowercased and held to the claim rule: fully qualified, at most 253 characters, at least two labels of 1–63 characters from [a-z0-9-] not starting or ending with a hyphen, a last label that is not all digits, and no wildcard, port, scheme, path, trailing dot or underscore. A host equal to or under one of the installation's platform domains is refused as well, because every host there is rendered from an application's name. One application holds at most 64 of them.

A host already claimed by another application is reference-held, and so is a host one label under a base somebody outside the application's own <name>-… family claimed with its subdomains, or a wildcard claim on a base where such an application already holds an exact host one label under it.

The claim is mirrored into that application's domains array — and into wildcardDomains when it covers its subdomains — referent-first: the application is checked, then the claim is created, then the application lists the host. A release does it the other way round, dropping the host from the application before the claim goes, so nothing ever points at a claim that is about to disappear. Both orders make the call safe to re-run.

A claim is created, upgraded from exact to covering, and deleted; it is never downgraded, because dropping the flag silently would take every subdomain out of routing.

#TLS object — _scafo/tls/objects/<key>

The value is stored in a JSON envelope, and the keys beneath the prefix are the caller's, not the registry's — nothing here parses them.

FieldTypeNotes
valuebase64 stringThe bytes that were written
modifiedISO stringTime of the write that produced them

An object under this prefix that is not such an envelope is store-nonconforming.

#TLS lock — _scafo/tls/locks/<name>

FieldTypeNotes
holderstringNames the node, so a record outliving its holder still says where it was
acquiredAtISO string
refreshedAtISO stringRestamped on every refresh; the field staleness is measured from
ConstantValue
Refresh interval5 s
Stale after10 s unrefreshed — another caller may take it over
Poll interval for a waiting caller1 s

A lock currently held is cas-conflict; a write by a holder that lost it is lease-lost. An unparseable refreshedAt counts as stale.

#CAS operations

The store must support conditional PUT with both If-Match and If-None-Match.

OperationPreconditionOn conflict
readJsonnoneUnparseable JSON is store-nonconforming
requireJsonnoneAbsence is not-found
putJsonwhatever the caller supplies, or nonecas-conflict
createJsonIf-None-Match: *cas-conflict when the key already exists
updateJsonread → mutate → If-Match: <etag>Retried from a fresh read
deleteKeynone — unconditionalTolerates absence

Documents are serialized as pretty-printed JSON (2-space indent).

#Retry and backoff

updateJson retries only cas-conflict by default, with full jitter.

SettingDefault
Total tries, including the first5
Base delay, doubled per attempt50 ms
Upper bound on the pre-jitter delay2000 ms
Delay drawnrandom() × min(maxMs, baseMs × 2^attempt)

The mutate function may run more than once and must stay free of side effects. An AbortSignal stops the loop before another read or conditional write once the caller loses authority.

Warning

S3 has no conditional delete, so deleteKey cannot be fenced by an ETag. Callers gate deletes behind the coordinator lease instead — which is why application deletion is coordinator-gated and forwarded.

A store that does not honour conditional writes, or answers a ranged read with the whole object or the wrong bytes, is caught at bootstrap by the probe object and reported as store-nonconforming: Store does not honour conditional writes for the write steps, Store does not honour ranged reads for the read. A store that cuts a listing short, or returns no ETag, is store-nonconforming too.

A listing must carry an ETag for every key it names, in the same form GET and PUT answer with. That is what lets a node list a kind and then read back only the records whose ETag has moved since it last looked, instead of reading all of them; a listing that carries none is store-nonconforming. Records are cached under the ETag the listing named, not the one the read answered with: they differ only when the record moved in between, and then the next listing has to see a difference and read it again.

#Celld fleet state

Each application has its own Celld fleet, whose state lives at fleets/<name> — outside _scafo, because it is handed to celld as a bucket path of its own (--bucket <bucket>/<prefix>/fleets/<name>).

These keyspaces are Celld's. Scafo reads them, to tell what an application is serving and what it has published before, and writes none of them.

KeyWhat it holds
deploy/current.jsonThe fleet-wide pointer: the sole selector of what the application serves
deploy/<script>/current.jsonOne script's own pointer
deploy/<script>/<version>/…One publication's objects, assets.json among them
deploy-blobs/assets/sha256/<2 hex>/<digest>The content-addressed asset blobs every deployment shares
deploy/images/<content key>.tarContainer images Celld saved, content-addressed and shared by every deployment that runs one. A keyspace of Celld's protocol, not a deployment
deploy/queues/<queue>/consumer.jsonThe one deployment allowed to consume that fleet-global queue
nodes/<node>.jsonOne Celld process's node lease, filed under the Scafo node id the process runs as: the record it must read or create before it has any authority, and the fence its peers judge it by

scafo deployment prune is the only thing that deletes under deploy/ and deploy-blobs/. It deletes under images/ only by reference — an image no kept deployment's manifest names, once it is older than the age window — and never touches queues/.

scafo app lease drop is the only thing that deletes under nodes/: one lease key the store will not read, on an operator's word, never a prefix. Both commands are unstable, run only against Celld releases this build was verified against, and warn on every run.

Read more in Reference > Cli#scafo Deployment Prune.
Read more in Reference > Cli#scafo App Lease.

#Observations

Observation flushes are Parquet files, one per flush, under a node-scoped and UTC-hour-partitioned prefix:

_scafo/observe/<node>/<yyyy>/<mm>/<dd>/<hh>/<epochMs>-<seq>.parquet
PartMeaning
<node>The node that wrote it, which is the only node allowed to delete it
<yyyy>/<mm>/<dd>/<hh>UTC calendar partition of the write
<epochMs>Write time in epoch milliseconds
<seq>The writer's flush number since that node booted, padded to 6 digits, so a listing sorts by write order
.parquetThe decoder the file needs

Pages are ZSTD-compressed. A key whose calendar partition disagrees with its timestamp is rejected when parsed, which protects the retention sweep. Read them back with scafo metrics, scafo traces, or any Parquet reader.

Read more in Docs > Observability.