Services
services.astrale.ai hosts Services: Cloudflare Workers an Identity owns. A Service is the stable
identity of one project in one environment. Every deploy adds an immutable deployment of it, and
the Service's URL serves the one it currently points at, like a project and its deployments.
Services is agnostic of what it hosts. An Astrale Domain release (astrale-domain deploy with
@astrale-os/adapter-astrale) and a generic Worker (a cron job, an API, a
create-astrale-service project) are the same thing to it: a Worker build,
the configuration it freezes, the secrets it binds and, when it publishes one, an opaque record.
Every Domain fact (origin, release, Schema revision, Publication, issuer) is the deployer's.
Provider protocols stay behind a neutral SDK Integration, and the graph keeps only admitted
lifecycle evidence.
Model
Service/CloudflareService: one(project, environment). A project is any name (PROJECT_NAMEof@astrale-os/sdk/deployment/worker); a Domain deployer names its Domain's origin. Itsnameis the opaque 16-characterserviceName(project, environment), unique among Services, the DNS label ofurl,https://<name>.<routing domain>.stateisactive, ordeletingwhile its deletion retires its deployments.service_owned_by: a Service has at most one owner, an Identity: a user, a group or a service account. It is chosen once, when the first deploy creates the Service, and changes only bytransfer. The Kernel cannot restrict the deletion of an Identity (kernel#1498): deleting the owner detaches the Edge, and the Service survives without an owner (below).Deployment/CloudflareDeployment: one immutable release of a Service, served by one Workers for Platforms script at its own URL,https://<label>.<routing domain>, wherelabelis<name>-<content16>and the content covers the build digest and the configuration digest. It keeps generic facts only:buildDigest, itsconfiguration(WorkerConfigurationV1) andconfigurationDigest, therecordit publishes if any (opaque bytes and their media type), its key id when it asked for a signing identity, the script's code hash, the keyed digests of its secrets, and its lifecycle:deploying→active→retiring→retired, orfailed. A retired deployment is never served again.deployment_of: the Service a deployment is a version of. A deployment has no owner of its own: it belongs to whoever owns its Service.current_deployment: the deployment the Service's URL serves and its crons run on. Promoting another deployment moves this Edge; a rollback is the promotion of an older one.
Secret values stay write-only at Cloudflare and never enter a Mutation or the graph. Schedules are provider state. Logs are read from the provider when asked.
Owner and authority
One Policy decides everything: OwnService, the subject is the owner of the Service. The Kernel
evaluates it over the caller and every Identity the caller reaches through extends_with, with
deny-wins. Nothing is selected at check time: a caller in no group reaches its own Services, a
caller in three groups also reaches the Services of each.
- A deploy that creates a Service makes the caller its owner, or the
ownerit names when the caller is or acts as that Identity (ActAs). - A service account deploys a Service when it owns it, or when it is a member of the group that
owns it (
Group.assignMemberwrites theextends_withEdge). A member has every right on every Service of the group, deletion included. OwnDeploymentgives a deployment to the owner of its Service.
Every Method is authenticated. Each handler asks the Kernel Identity.can for the caller on its
own session before it reads anything: a caller who does not own the Service finds none
(SERVICE_NOT_FOUND, DEPLOYMENT_NOT_FOUND), and a deploy of another owner's Service is refused
service-owned-elsewhere.
list reads Services under the Services Domain credential and keeps those the Kernel answers
OwnService for, in batches: a caller-mode read does not reach the Identity end of
service_owned_by for an owner the caller reaches through extends_with
(kernel#1138).
A Service without an owner
Once its owner Identity is deleted, a Service is nobody's: no caller satisfies OwnService on it,
not even the Kernel root, whose Identity.can answers are factual. Only an authority beyond
ownership still reaches it, such as the root writing the graph directly. The handlers treat it as
a declared state:
- Its Methods find no Service (
SERVICE_NOT_FOUND), as for any Service the caller does not own. - A deploy of its project and environment is refused
service-owned-elsewhere, with a message that it has no owner and must be transferred to one first. listnever lists it.- A
transferor adeletewhose caller proved it owned the Service before its owner was deleted completes: the transfer gives the Service the owner it names, and the deletion deletes it without an owner Edge. Each Mutation fences on the owner it read, or on the Service still having none.
Methods
Call them on the Admin instance (astrale call ... --admin). Their wire contract is
@astrale-os/sdk/deployment/worker's.
| Method | On | What it does |
|---|---|---|
deploy | CloudflareService (static) | Hosts one Worker release (WorkerDeploymentRequestV1) as a deployment of the Service of its project and environment, creating the Service on its first deploy, and by default (promote absent or true) makes the Service serve it. Answers WorkerDeploymentResponseV1. |
hosting | CloudflareService (static) | The routing domain Services and deployments are addressed under (WorkerHostingV1); names are always opaque. |
list | CloudflareService (static) | The deployments of the Services the caller owns (WorkerDeploymentSummaryV1), narrowed to a project and an environment when given, each with current and the record it publishes. |
promote | CloudflareService | Points the Service at one of its active deployments, named by its label. |
transfer | CloudflareService | Gives the Service to another owner the caller is or acts as. |
setSecret, secrets | CloudflareService | Rewrites one declared secret of the current deployment in place; lists the names it binds, never a value. |
setSchedule, schedules | CloudflareService | Replaces or reads the Service's UTC cron set; each cron fires on the current deployment. |
logs | CloudflareService | Recent logs of the current deployment, or of one deployment named by label. |
delete | CloudflareService | Stops routing the Service, retires every deployment of it, then deletes it and frees its name. |
retire | CloudflareDeployment | Withdraws one deployment for good. The current deployment is never retired alone. |
Deploying
deploy is a non-durable Action: its input holds the secrets, and no Execution, step result or
journal entry keeps them.
- Before any read, the SDK's
decodeWorkerDeploymentRequestadmits the request: every file decoded within its bounds, the configuration canonical, a record exactly when the configuration asks for one, the secrets exactly the names it declares. It recomputes the build digest from the uploaded bytes (build-mismatch) and the label from the Service name, the build and the configuration (address-mismatch); anything else isinvalid-deployment. A configuration that asks for the router where this environment has none ishosting-unavailable. - It reads the Service of the name under the Services Domain credential and proves the caller's
authority on the caller's own session:
OwnServiceon an existing Service,ActAson the owner it names for a new one. - A new label is created under a graph lease. The provider uploads it create-only with the host
bindings (its URL as
WORKER_URL, the recomputedASTRALE_BUILD_DIGEST, the version metadataASTRALE_VERSION_METADATA), the configuration's variables, a signing key generated for this deployment alone (ASTRALE_SIGNING_IDENTITY) only when it askssigningIdentity, the router (ROUTER) only when it asksrouter, and the request's secrets. When the request names readiness paths, each must answer 200 from the uploaded version (x-astrale-worker-version); none skips the probe. The label is then activated through the routing KVrecord:<label>with the record the deployment publishes, served byte for byte with its media type at/.well-known/astrale/deployment.json, or a placeholder when it publishes none. The graph records it active with its code hash, its key id when it has one, and keyed HMAC-SHA256 digests of its secrets. - The same release again is the same label:
deployanswersreused. Its secrets are compared with the recorded digests:unchanged, or rewritten in place on that deployment alone,updated. - When it promotes,
current_deploymentmoves to the deployment, fenced on what the Service served, and the routing KVcurrent:<name>names its label. A routing write that failed is answeredservice-unavailable; deploying or promoting again converges.
The names the host binds itself (WORKER_HOST_BINDINGS: WORKER_URL, ASTRALE_BUILD_DIGEST,
ASTRALE_VERSION_METADATA, ASTRALE_SIGNING_IDENTITY, ROUTER, ASSETS) are never a variable
or a secret of the request.
- A Domain is deployed by
astrale-domain deploy <environment>withastrale()of@astrale-os/adapter-astrale: its project is the Domain's origin, its build manifest members are the Domain's (soASTRALE_BUILD_DIGESTis the sealed release's), its execution settings travel as theASTRALE_SETTINGSvariable, it publishes itsDeploymentRecordV1and asks for a signing identity, and readiness probes the release's documents. The adapter, not Services, verifies the release served and installs it deliberately. - Any other Worker is deployed with
@astrale-os/adapter-astrale/services(readWorkerDirectory,deployWorker,callService), as the projectscreate-astrale-servicegenerates do.
Routing and crons
The Services dispatcher is the hosting kit's dispatcher (@astrale-os/adapter-cloudflare/hosting),
with host-based deployment routing only; retired tenant routing is no longer provisioned.
It serves one routing domain:
https://<label>.<routing domain>serves that deployment:record:<label>activates it, andretirement:<label>answersDEPLOYMENT_RETIRED(410). It answers the deployment's record at/.well-known/astrale/deployment.jsonitself; a deployment that publishes none answers that path.https://<name>.<routing domain>serves the deploymentcurrent:<name>names, through the same path, so a retired deployment is never served through its Service either.- Every minute the dispatcher reads the
cron:<name>keys and posts each due cron to the current deployment of that Service:POST https://cron.internal/__scheduledwith the expression inx-astrale-cronand the minute it fired for, in epoch milliseconds, inx-astrale-scheduled-time, within a 10-second deadline. A non-2xx answer is logged. The public never reaches/__scheduled: the dispatcher refuses it in every spelling (case, repeated or trailing slashes, percent-encoding) before any lookup. A run is not exactly-once: a job must be safe to repeat.
Secrets
Services never keeps a value. A deploy binds the request's secrets to the new deployment only;
every other deployment keeps its own. setSecret rewrites one name the current deployment's
configuration declares, in place, under the deployment's graph lease: it compares the keyed
digests, writes only changed values, forgets the digests of the names it writes first and records
the new ones once every write succeeded. The provider serves a new value a few seconds later. A
new name, or removing one, is a new release; a signing key never rotates.
Never pass a secret value in argv. Write the input to a file only you can read, outside the repository:
{ "name": "API_TOKEN", "value": "<the new value>" }
astrale call "@$SERVICE::services.astrale.ai:class.CloudflareService.method.setSecret" \
--admin -d @rotate.json --json
Put the new value in the environment's secrets as well: the next deploy binds the values it carries.
Rollback and retirement
astrale call /:services.astrale.ai:class.CloudflareService:list \
project=agencies.example.com environment=production --admin --json
astrale call "@$SERVICE::services.astrale.ai:class.CloudflareService.method.promote" \
label=$PREVIOUS_LABEL --admin --json
retire withdraws one deployment in two phases: the first call records it retiring and writes
its tombstone, and a call after retryAfterMs (once every dispatcher reads the tombstone, about 65
seconds) deletes its script and key and answers retired.
astrale call "@$DEPLOYMENT::services.astrale.ai:class.CloudflareDeployment.method.retire" \
--admin --json
delete marks the Service deleting, removes current:<name> and cron:<name>, and retires every
deployment. It answers deleting with retryAfterMs until the last one is retired, then deletes
the Service with its deployments in one Mutation and answers deleted. The name is then free
again.
Generic Services
The package ships an isolated scaffolding binary (the root import remains Schema-only):
npm exec --package @astrale-domains/services -- create-astrale-service nightly-report
cd nightly-report
npm install
# Edit service.json and src/job.js; put the secret values in .env.production.
npm run deploy -- production
The generator creates local source only and refuses an existing destination: a plain Worker
(src/index.js answering GET /health and running src/job.js on POST /__scheduled), an
optional ES256 account session to call an Astrale Kernel (src/account.js), service.json (the
project, the Workers runtime, the variables, the secret names, readiness paths and crons) and its
build (esbuild) and deploy scripts. npm run deploy -- <environment> builds it, deploys it through
@astrale-os/adapter-astrale/services as one immutable deployment promoted by default
(--no-promote deploys beside the current one), then replaces the Service's crons with
service.json's. It installs, registers, grants and schedules nothing else. The generated README
explains promotion and rollback, secrets, crons, logs and deletion. The scaffold lives in
tooling/scaffold; pnpm package copies it to dist/scaffold.
Admin-hosted runtime
Services are hosted for the Admin instance's Kernel alone: its graph is the registry of Service names, while the deployments namespace and the routing KV are shared. A Domain Worker serves every Kernel that installs it; any other Kernel finds hosting unavailable before anything is read.
The admin Environment sets the plain vars ASTRALE_ROUTER_WORKER, SERVICES_DEPLOYMENT_HOST (the
routing domain) and SERVICES_DEPLOYMENT_KERNEL (the Admin Kernel's issuer URL). These vars are the
only declaration of hosting and also bind SERVICE_DEPLOYMENTS. Its secrets file, .env.admin,
sets SERVICES_SECRET_DIGEST_KEY (at least 32 UTF-8 bytes), CLOUDFLARE_DISPATCH_NAMESPACE and
CLOUDFLARE_SCHEDULE_KV_ID; the configuration refuses a file without them or one that sets a
hosting var.
.env.admin.tpl is that file with each secret as a 1Password reference.
scripts/with-secrets.sh admin -- <command> resolves it for one command: .env.admin (0600)
exists while the command runs, its values are in the command's environment, and the file is
removed afterwards. A resolved .env.admin left on disk is refused rather than overwritten.
pnpm run deploy admin makes one immutable deployment of this release in the platform dispatch
namespace. Deploying never installs: install the deployed URL with
astrale domain install <url> -i <instance>. The Cloudflare Provider and its operator tooling
are built on the hosting kit: provisioning both dispatchers (the Services environment's and the
platform namespace's), audits, platform retirement and secret rotation are described in
providers/cloudflare/operator/README.md.
Domain development
Services is developed against its tests: the hosting kit's fake Cloudflare account
(@astrale-os/adapter-cloudflare/hosting/testing, running the dispatchers as uploaded, and the
scaffolded Worker itself) and the real-Kernel suite.
pnpm typecheck
pnpm test
pnpm lint
pnpm frontend:build
pnpm build
pnpm package