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
| Record | Key | Id 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>.json | token is a content id |
| Node | _scafo/nodes/<id>.json | node id |
| Trusted operator key | _scafo/operators/<id>.json | operator id |
| Identity reservation | _scafo/identities/<id>.json | node id charset; one shared namespace for nodes and operators |
| Placement | _scafo/placement/<name>.json | application 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>.json | validated host |
| Application | _scafo/applications/<name>.json | application name |
| Trust policy | _scafo/trust/<name>.json | application name |
| Deployment | _scafo/deployments/<name>/<version>.json | application name; version a content id |
| Deploy | _scafo/deploys/<name>/<id>.json | application 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 state | fleets/<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
| Kind | Pattern | Failure |
|---|---|---|
| Content id (deployment versions, probe tokens) | ^[a-f\d]{8,64}$ — 8–64 lowercase hexadecimal characters | invalid-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 allowed | invalid-slug, Invalid node id |
| Operator id | Same charset as a node id, to preserve the shared identity space | invalid-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 limit | invalid-slug, Invalid slug |
| Deploy id | An application name, a dot, and 16 lowercase hexadecimal characters. A name carries no dot, so the first dot is the separator | invalid-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 shop | invalid-input, Invalid scope entry |
| TLS key or lock name | Relative (no leading /), printable ASCII only, no empty, . or .. segment | invalid-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
| Field | Type | Notes |
|---|---|---|
v | 1 | |
platformDomains | string[] | Apex domains the platform serves wildcard subdomains under |
createdAt | ISO 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.
| Field | Type | Notes |
|---|---|---|
v | 1 | |
frozenAt | ISO string | |
by | string | Operator id of the admin who froze deploys, as their token was verified — never anything a request body said |
reason | string? | 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.
| Field | Type | Notes |
|---|---|---|
v | 1 | |
token | string | 16 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:
| Situation | Consequence |
|---|---|
| The hint could not be written | One line in the log. The mutation itself happened, and other nodes see it on their next full resync |
| The hint is absent, empty or unreadable | Read as "no token", which falls back to the resync timer — the same behaviour as a lost hint |
| The store could not be reached | Raised. That is a registry read failing, and the caller has to know |
#CoordinatorLease — _scafo/coordinator/lease.json
| Field | Type | Notes |
|---|---|---|
v | 1 | |
node | string | Id of the node holding the lease |
acquiredAt | ISO string | |
ttlMs | number | Lifetime this acquisition claims, from coordinator.leaseTtlMs (45 s by default). A release writes 0, so the next node takes it at once |
fence | number | Monotonically 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
| Field | Type | Notes |
|---|---|---|
v | 1 | |
id | string | |
publicKey | { kty: "OKP", crv: "Ed25519", x: string } | Verifies the node's signatures |
addresses | { internal: string, apiPort: number } | |
heartbeatAt | ISO string? | Last time the daemon said it is still there. This is the liveness signal placement decides from |
drainingAt | ISO string? | Graceful-shutdown marker. A draining node is ineligible despite a heartbeat |
wireguard | object? | { 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 |
labels | Record<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 |
serving | Record<"<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
| Field | Type | Notes |
|---|---|---|
v | 1 | |
kind | "node" | "operator" | |
publicKey | Ed25519 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
| Field | Type | Notes |
|---|---|---|
v | 1 | |
id | string | |
publicKey | Ed25519 JWK | Every token this operator signs is verified against it |
role | "admin" | "operator"? | Omitted legacy roles read as admin; new records always set it |
supersedes | string? | Key this one was registered to replace. It is what authorizes the contract half of a rotation |
applications | string[]? | 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.
| Field | Type | Notes |
|---|---|---|
v | 1 | |
name | string | The application name; also the key segment |
createdAt | ISO string | |
blocked | true? | Stops deploys, runtime, routing and certificates without deleting anything. Absence is unblocked |
labels | Record<string,string>? | Fleet-owned metadata, replaced wholesale, never interpreted, and stripped from a non-admin's view |
deployment | { revision: number, deployedAt: string } | null | Monotonic deployment notice; null until the first successful deploy. Never names a version |
domains | string[] | Custom domains claimed for it |
wildcardDomains | string[]? | 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 |
replicas | number | "all"? | Omitted resolves to every live node that may serve it |
nodeSelector | Record<string,string>? | Exact-match AND selector; a node needs every listed pair |
retiredAt | ISO 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
| Field | Type | Notes |
|---|---|---|
v | 1 | |
nodes | string[] | Nodes that should run this application, lexicographically sorted |
decidedAt | ISO string | |
fence | number | Lease 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.
| Field | Type | Notes |
|---|---|---|
v | 1 | |
version | string | The 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 |
createdAt | ISO string | Stamped 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.
| Field | Type | Notes |
|---|---|---|
v | 1 | |
id | string | <name>.<16 hex>; the application half is what authenticates a poll |
application | string | |
state | accepted | uploading | deployed | failed | |
node | string | Node that accepted the deploy and is carrying it out |
acceptedAt | ISO string | |
updatedAt | ISO string | When the state last moved; the reaper reads it to spot an abandoned deploy |
deployment | string? | Deployment the deploy produced, by version; absent until one has been recorded |
code | error code? | The code of the error that failed it, when it was a typed one |
detail | string? | 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.
| Transition | Written by |
|---|---|
Create, as accepted | The 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, deployed | The same node, as its publish proceeds |
failed | The same node, carrying the code of the error that failed it |
failed with deploy-timeout | The 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 |
| Deleted | The 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
| Field | Type | Notes |
|---|---|---|
v | 1 | |
issuers | array | Each { 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
| Field | Type | Notes |
|---|---|---|
v | 1 | |
application | string | The application the host is claimed for |
claimedAt | ISO string | |
wildcard | true? | 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.
| Field | Type | Notes |
|---|---|---|
value | base64 string | The bytes that were written |
modified | ISO string | Time 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>
| Field | Type | Notes |
|---|---|---|
holder | string | Names the node, so a record outliving its holder still says where it was |
acquiredAt | ISO string | |
refreshedAt | ISO string | Restamped on every refresh; the field staleness is measured from |
| Constant | Value |
|---|---|
| Refresh interval | 5 s |
| Stale after | 10 s unrefreshed — another caller may take it over |
| Poll interval for a waiting caller | 1 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.
| Operation | Precondition | On conflict |
|---|---|---|
readJson | none | Unparseable JSON is store-nonconforming |
requireJson | none | Absence is not-found |
putJson | whatever the caller supplies, or none | cas-conflict |
createJson | If-None-Match: * | cas-conflict when the key already exists |
updateJson | read → mutate → If-Match: <etag> | Retried from a fresh read |
deleteKey | none — unconditional | Tolerates absence |
Documents are serialized as pretty-printed JSON (2-space indent).
#Retry and backoff
updateJson retries only cas-conflict by default, with full jitter.
| Setting | Default |
|---|---|
| Total tries, including the first | 5 |
| Base delay, doubled per attempt | 50 ms |
| Upper bound on the pre-jitter delay | 2000 ms |
| Delay drawn | random() × 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.
| Key | What it holds |
|---|---|
deploy/current.json | The fleet-wide pointer: the sole selector of what the application serves |
deploy/<script>/current.json | One 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>.tar | Container 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.json | The one deployment allowed to consume that fleet-global queue |
nodes/<node>.json | One 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.
#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| Part | Meaning |
|---|---|
<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 |
.parquet | The 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.