docsOperate

Deployables

A deployable is something this cluster hosts for someone. It is the surface that used to be called Sites, widened so that a person -- not only an operator -- can put a thing on the internet.

The row behind it is still v1:platform:site, and the edge still resolves a request's Host header to one of those rows and serves its bundle. What changed is who may own one, where it may live, where its bytes may come from, and -- with epic memql#4885 -- the fact that composing one, watching it deploy and managing it afterwards are now one flow rather than three sections.

This page is the deployable: the flow, the address, the credential a private source needs, and the target model. Its other half is Packages and deployables, which is the SOURCE: the manifest, the pipeline, the DSL delivery and the refusal catalogue.

Design records: the Deployables program and Compose.


The one flow

Every deployable is composed, deployed and managed on one vertical device with five stops, in this order:

StopWhat it answers
Sourcewhere the thing comes from -- a repository, a zip in Files, or your own CI
What it iswhat the analysis found: each app with its kind, path and build plan, each DSL domain, any Go pack, and every problem
Where it livesthe address: a hostname under this cluster's domain, the client it is for, and -- for a cluster owner -- the client's own domain
Buildprebuilt output found and skipped, or the typed refusal saying what would have run
Livenot deployed, built, live, offline or archived, with the version history behind it

The same five stops report progress while a deploy runs and standing status afterwards, so there is no separate "progress screen" to learn. Beneath them sits Every attempt: the append-only deployment rows, each with its own six-stage rail, and roll back on any successful one that is not the latest.

Chosen once. A source is picked once and shown as facts afterwards; an address is chosen once, at a deployable's first deploy, and remembered on its site row. A later deploy of the same source keeps the same addresses and never re-asks.


The three sources

A repository

An HTTPS github.com URL and, optionally, a branch, tag or commit. An empty ref follows the repository's default branch, resolved at fetch time -- storing the resolved default would freeze the package to whatever main meant on the day it was added.

The URL is probed before anything is created (sourceProbe, below). A reachable public repository reads "public, default branch main". One the cluster cannot see reads "private, or not there", and a credential field appears offering your active credentials for that host, or a new one.

Only github.com is fetched today. Any other host is refused with source_host_unsupported -- "only github.com today, or upload a zip" -- and the answer is the zip source below. Connecting a GitHub account instead of pasting a token is the GitHub Connect epic, which lands separately; what ships here is the pasted token.

A zip in Files

A zip artifact already in your Library. Choosing one calls artifactProbe, which decides which of two paths the zip takes:

  • memql-package.yaml at the root -- it is a package tree, and the package path takes it from here (packages.md).
  • index.html at the root and no manifest -- it is a built site, and the hand-made path takes it and asks for the kind.
  • Neither -- the zip is something else, commonly a folder wrapping the site one level down.

A manifest beside an index.html is a package: the manifest is the stronger claim. Choosing a zip deploys nothing.

Pushed by your CI

Cluster owners only. The deployable is created as a draft holding the placeholder bundle blob://sites/<siteId>/pending/ (the convention site-hosting.md records), and its Source stop then shows the site id, the publish route, and the command that mints the credential for it:

text
POST https://api.<domain>/sites/<siteId>/bundles
shell
TOKEN="$(kubectl -n memql exec deploy/identity -- \
memql service-account-token mint --label ci-publish --subject system:ci-publish)"

That route takes a class="service_account" JWT and nothing else -- a signed-in person's session gets a 403, not a 401. The full worked example, including why every file rides its own multipart part named for its path within the bundle, is in site-hosting.md; the credential itself is service-account-jwt.md.

The Live stop then waits for the first push, which arrives as the bundleRef flip the site row already broadcasts. Nothing polls.


Two clicks on a first deploy, one after

Analyze creates the source record and parks a run at the confirm gate. Deploy confirms it with the placements. A redeploy is Deploy alone.

On the package path:

ClickCall
AnalyzecreatePackage, then packageDeploy(confirm: false)
DeploypackageDeploy(confirm: true, placements, deploymentId)

packageDeploy without confirm opens the deployment row, fetches, analyzes and parks at awaiting_confirm with the report on the row. Nothing is built, staged, rolled or published. That gate is always present -- a redeploy passes it in one click, and it is on the ROW rather than in a browser, so somebody who closed the window finds their run exactly where they left it.

The second click ADVANCES the first click's run, which is what deploymentId names (memql#4954). Without it every call minted a run, the confirmation included: the source kept a row at awaiting_confirm nobody would ever answer, the list went on saying a deploy was waiting for a gate that had been answered, and a Retry's report described bytes the click did not ship. A resumed run also re-reads the snapshot it already stored rather than fetching the source again, so the report a person read at the gate describes what the click deploys.

Only a PARKED run of the same source resumes. A deployment id naming a run in flight falls through to opening a new one, which is what leaves the append-only rule refusing it -- the dedup two auto-deploy feeds noticing one push rely on.

On the hand-made path, Analyze is createSite (a draft holding the placeholder bundle) plus, for a zip, artifactProbe; Deploy is sitePublishFromArtifact for a zip and nothing at all for a CI-pushed source.

A parked run is findable without opening anything. A deployable whose source has a run at awaiting_confirm is marked in the list -- "a deploy is waiting for you" -- from a feed over parked runs alone (packageDeploymentsAwaitingConfirm, owner or cluster owner, newest first).

A run says which deployables it is for

scopedTo on the run row names them; empty means the whole source, which is what a run that skips nothing is and what every run written before the field existed was.

Runs are routinely scoped to one app -- a redeploy from a deployable's page sends every sibling skip: true, and the compose gate does the same when entered for one declared app -- and the row used to keep no trace of it (memql#4953). So every reader assumed the source's newest run was about whatever it was looking at: a serving storefront read itself as "Building" while web deployed, drew its own Where-it-lives, Build and Live stops as unreached, rendered the sibling's report and build failure as its own, and offered a Cancel that stopped the other app's deploy.

Two things follow from a run knowing its own scope, and the second is not merely cosmetic:

  • A deployable's page shows a run only when that run names its app, and the list's compact rail stops repainting live siblings as unreached.
  • While any run of a source is in flight, that source's other apps offer no act that would start a second one, and say why. The wrong reading was accidentally providing this -- every app of a busy source read "Building" and offered only Cancel -- and there is no per-source concurrency gate in the engine, while the roll rewrites one pointer and restarts the cluster onto it.

Placements: the address, the client, the domain

packageDeploy takes placements, an object keyed by deployable name:

text
placements: {
"storefront": {
"hostname": "shop",
"accountId": "v1:accounts:account:...",
"ownDomain": "shop.acme.com"
}
}

Read on a first deploy only. A never-deployed app with no hostname is refused with deployable_hostname_unchosen; later deploys find the site through (packageId, packageDeployableName) and never re-ask.

The address starts as a generated name and is checked while it is typed. The Where-it-lives stop seeds each app with a two-word name rather than the app's own -- every source declares storefront and web, so the first person on a cluster took those and the second found them taken at the end of the flow -- and asks siteHostnameCheck (and customDomainCheck for a client's own domain) a beat after each pause. Both answer {hostname, available, reason, problem} from the write guards' own shape and uniqueness rules and reserve nothing: the line under the field reads "checking", then "free" or the policy's own sentence, Generate draws again until a free name comes back, and Deploy stays out of reach until every address going out has checked out. A name free a moment ago can still be refused on the write, by the same guard.

The two optional halves are applied by the pipeline after the site exists, as the same updateSiteAccount and customDomainAdd calls the page makes, run under the caller's actor. The existing guards therefore decide exactly as they do from the page, and the pipeline gains no bypass of either; it only saves the person a second click.

A refused half does not fail the publish. The site is live at its cluster address either way, and a hostname collision or a per-site cap is a fact about the domain rather than about the deploy -- so it lands on that deployable's outcome as deployable_account_refused or deployable_domain_refused, with the guard's own sentence, for the Where-it-lives stop to render. What DID land is recorded beside it as accountId and ownDomain.

The deploy never waits on DNS. The app goes live at its cluster address and a bound domain stays "waiting on your DNS records" until both records check out. The verification, the two records and the certificate are the custom-domain flow (front-door.md).


What a deployable is called, and what you can do to it

One vocabulary, on every surface (the record is docs/superpowers/specs/2026-09-05-deployables-states-activation-and-source-archive-design.md). The enum values on v1:platform:site.status are unchanged; these are the words a person reads:

WordEngineMeaning
Inactivedeclared, on the source's disabledDeployables, no siteskipped when the source was deployed; nothing built, no address
Not deployeddeclared and never deployed, or draft with the placeholder bundlenothing has landed here yet
Builtdraft with real filesits files are in place at the address; not live until you say
Liveliveserving at the address
Offlinedisabledtaken offline on purpose; visitors get "temporarily unavailable" rather than "no such site"
Archivedarchivedfiled away; answers nothing; the name is still held
Deletingdeleted with domains still coming downreleasing the address

The verbs: Analyze; Deploy (and Deploy the update, Redeploy, Retry the deploy), which builds the source and puts its files in place and never changes whether the app is live; Go live / Take offline; Activate / Deactivate for a source's app; Archive / Restore for a source or a standalone; Delete (and Discard for a draft) for a standalone; Cancel for a run; Skip at the gate.

A source's app and a standalone are two ladders. A standalone's bundle is its only copy, so it climbs Offline -> Archived -> Deleted: Archive keeps the name and is reversible, Delete releases it. A source's app is reproducible from the source, so it has one destructive act from every state, live included: Deactivate (packageDeactivateDeployable) releases its custom domains, deletes its site so the address comes free, disarms the source's auto-deploy when this was its last app, and puts the name on the source's off-list. The confirmation is the app's manifest name. Activate is the inverse's first half: the app comes off the off-list and the compose flow opens for it, scoped to it, asking only where it should live.

Skip is deactivate. An app skipped at the gate lands on the off-list and reads inactive. Clicking it -- in the list or on its source's page -- opens the compose flow for that app with a notice saying it is inactive and Activate on the bar; nothing is written until Activate is pressed.

Archiving a source deactivates every app it produced, then archives the source. Live apps included: the confirmation names the live addresses and says they go offline the moment it is confirmed. Restoring the source brings its apps back inactive. See packages.md.

A confirmation is the thing's name. A standalone types its address label (storefront, not the whole hostname; the server accepts either), a source's app types its manifest name, a source types its own.

A first deploy ends on Built, with the files in place and the site not live, deliberately -- a stranger's code should not go live the moment it builds. The compose flow offers Go live beside Done, so the two-step is one screen.

Go live is offered only when the engine would accept it. Both the deployable's bar and the end of the compose flow ask sitePreviewReadiness, which answers from the same rule the write guard refuses with. A storefront with no connected store may go live for design review. Catalog, cart and checkout become available after connection through the Store icon beside Traffic. An amber marker identifies incomplete Shopify setup independently of Live status. A bound development store or unreadable store still refuses publication. GitHub accounts and organization access are managed in Settings; the repository wizard selects that saved access, with an accent text action back to Settings.


The target model

A target is what the flow needs to know about a kind of deployable: the address stop's shape, the build surface, the live states, and the row it lands on. Every stop renders from it, so the flow has no branch on "which kind is this".

TargetAddressBuild surfaceLive statesRow
web (spa, static, shopify_storefront)a hostname under the cluster domain; optionally the client's own domainprebuilt output in the sourcedraft, live, disabled, archivedv1:platform:site
iosa bundle id and an App Store Connect appa Mac in your Fleetbuilt, uploaded, in review, releaseda new concept, never site
androidan application id and a Play listinga workbench with a JDK and SDKbuilt, uploaded, in review, releasedthe same new concept
macosa bundle id; a notarized disk image or the Mac App Storea Mac in your Fleetbuilt, notarized, releasedthe same new concept

Only web is registered. The three rows beneath it are a written-down shape, not code: nothing renders a control for them, and v1:platform:site.kind declares exactly the three web kinds (TestSiteKindEnumIsExactlyThreeValues). A site is a hostname the edge resolves, and a store listing is not one, so an ios site row would be a value that never resolves.

The engine tells the truth about a kind it knows and does not offer. A manifest declaring ios, android or macos is reported with deployable_target_not_offered -- scoped to that app, not fatal to the package, exactly as a Go pack is:

iOS is not offered on this cluster yet. Its address will be a bundle id and an App Store Connect app, and nothing here serves one today; the rest of this package deploys around it.

The app is skipped with the build plan "skipped -- not offered on this cluster yet" and recorded on the deployment row with that non-fatal refusal and no site id -- recorded rather than omitted, because the row promises one entry per manifest deployable and a missing entry reads as "nothing happened". A kind nobody has heard of stays deployable_kind_unknown, which is fatal: the two say opposite things to an author, "not yet" versus "not a thing".

One list, pinned twice. The OS's offered-kind list and the site enum are held equal by a parity test, in the tradition of TestFleetOnlineWindowMatchesTheClients, so neither can grow a value the other does not have.


Private sources: the credential

A private repository is fetched under a credential you own.

v1:platform:sourceCredential declares @rowAuthz(owner="ownerUserId", clusterOwner):

FieldNotes
ownerUserIdstamped from the actor by the writer, never accepted as an argument; the row-authz key
hostgithub.com today, and the only value admitted. The probe and the fetcher match on it, so a credential can never be presented to a host it was not minted for
labelyour own name for it. Display only
encryptedValuethe token, sealed by secret.Encrypt under MEMQL_MASTER_KEY on the node that received it. No client-readable shape projects it
fingerprintthe token's last four characters, prefixed with ..., for telling two apart
statusactive or revoked
lastUsedAtwhen a fetch or the poll last unsealed it. A HEARTBEAT: displayed, never treated as news
revokedAtwhen it was revoked. The row is never deleted

The token crosses the wire once. sourceCredentialCreate({host, label, token}) seals it server-side and answers {credentialId, fingerprint}. It is a function-local for the length of that one call and appears in no row, no log line and no reply. It is a capability rather than a mutation for one reason: a secret cannot be sealed in a browser, because MEMQL_MASTER_KEY exists on nodes and must never exist on a laptop.

A package NAMES a credential, on credentialId. There is no token on the package row, on a snapshot, or in a log.

How it resolves, and why that is the whole security shape

The fetcher and the polling feed read the credential under the package owner's actor, through a query whose only predicate is the owner term. So:

  • A package naming somebody else's credential resolves zero rows and is refused credential_not_found, before any request leaves the cluster. "Does not exist" and "belongs to somebody else" are the same zero rows, and the sentence does not claim to know which.
  • A credential whose status is revoked is refused credential_revoked.
  • A cluster owner deploying somebody's package fetches under that package's own credential -- which is correct: they are deploying that package. There is no cluster-wide source credential any more.
  • The polling feed skips a package whose credential will not resolve rather than polling it anonymously, so a private repository never reads as unchanged because the request answered 404.

Decryption happens only inside a fetch, or the probe that stands in for one. No query and no capability returns plaintext; the one query that returns ciphertext is @serverOnly.

This replaces the old named cluster secret outright, with no shim. A package used to name a cluster-wide v1:platform:globalSecret, which had two defects that were one defect: nothing in the OS could create one, so a private repository could only be connected from the cockpit CLI; and any package owner could name any secret, so the fetcher could be made to fetch under an operator's token by whoever knew its name. A package row still carrying a secret NAME from before reads as "no credential" and is asked for one.

Revoking and rotating

sourceCredentialRevoke({credentialId}) flips status to revoked and stamps revokedAt through an owned mutation, so the write guard admits the row's owner (or a cluster owner) and nobody else. The row is never deleted -- it is the history of what fetched under it -- and every source fetching under it refuses at its next fetch with credential_revoked until it is switched.

Rotation is adding, then switching. There is no in-place replace: add a new credential and point the source at it on its Source stop (updatePackageSource). A revoked credential is never reactivated.

The Sources group, in the Deployables app's Settings section, is where the whole set lives: each credential's host, label, fingerprint and last use, plus the sources fetching under it (a join on credentialId), with add and revoke. Revoke says what it will cost: sources fetching under it will refuse at their next fetch until you switch them.


The two probes

Both are read-only questions the Source stop asks before a person commits to a source. Neither writes anything and neither stamps anything -- not even the lastUsedAt heartbeat a fetch records, because a probe is a question and a question is not a use.

sourceProbe({repoUrl, credentialId})

Answers {host, reachable, private, defaultBranch, reason}. private and defaultBranch are meaningful only when reachable is true; a repository the probe could not read has no known visibility, and false there is "not known", never "public". reason is exactly one of:

ReasonWhat it means
okreachable; private and defaultBranch are GitHub's answer
not_found_or_private404 with no credential. GitHub answers the two alike, and the stop offers a credential
credential_cannot_see_itrefused under the credential (401, 403 and 404 alike -- one repair for the three). Choose another, or widen its grant
credential_not_foundthe caller cannot read the named credential
credential_revokedit resolves, and it was revoked
source_host_unsupportednot github.com. Paste a github.com URL, or upload a zip
rate_limitedGitHub is rate-limiting; ask again later

A GitHub this cluster cannot reach at all is an error, not a reason -- the stop says so and stays editable. It never blocks Analyze on a public repository: the fetch is the authority and the probe is a courtesy. The credential resolves under the CALLER's own actor here (the person composing is choosing among their own credentials), and a typed reason is answered before any request leaves the cluster.

artifactProbe({artifactId})

Answers {isPackage, isBuiltSite, fileCount, totalBytes} by opening the caller's own zip through the same fetch a deploy uses -- expanded under the packages limits, so a zip the deploy would refuse (too large, an entry escaping the root, not a zip) is refused here, by the same code, before anybody commits to it. The artifact stays exactly the Library row it was.


Ownership

v1:platform:site declares @rowAuthz(owner="ownerUserId", clusterOwner): the owner, or a cluster owner. A cluster owner still sees every site, which is the capability the old clusterOwner-only tier existed to protect -- the composite keeps "list every site in this cluster" expressible while adding per-user ownership underneath it.

ownerUserId is stamped from the actor by createSite; there is no ownerUserId argument. Go only ever NARROWS it: a privileged writer (cluster owner, internal origin, system actor) has the stamp removed, and only when the stamped value is the caller's own id. The outcome is therefore "the caller owns it" or "nobody does" -- it can never name a third party.

An empty ownerUserId means cluster-owned. The seeded OS site row keeps it empty and lands cluster-owned on every boot, and so does the VS Code landing page's row (vscode.<domain>, memql#5518) -- the second platform site, seeded from the same file and behaving identically: managed by the cluster, no acts.

Consequences worth stating plainly, because they are surprising:

  • A cluster owner cannot personally own a site. Theirs are cluster-owned.
  • Handing a site over, or taking one back, is re-running createSite on the same id. There is no owner-assignment mutation: one writing the owner from an argument could not exist without failing the owner-stamp gate or lying about why it was exempt.

The erasure, and its repair (memql#5292). Between the arrival of self-serve deployables and PR #5284, every developer deploy produced a site whose owner was then blanked: createSite stamped the developer, and the pipeline's next write (recordSitePackageOrigin, internal origin on the same context) tripped the privileged undo above on a value it had not stamped, and deleted it. The row read as cluster-owned, so the developer who deployed it could neither see it in Deployables, flip it live, nor delete it. PR #5284 makes the undo fire only on a write whose delta named the owner. The rows it already left are repaired by the versioned engine migration 20260912000000_site_owner_restamp (component/database/), which runs at boot like every other: it selects v1:platform:site rows whose LATEST version has an empty owner and a non-empty packageId (never systemOwned, never deleted), takes the owner from the newest packageDeployment for that package whose deployables[] names the site (its requestedBy), falls back to the package row's own owner, and appends ONE new version with the owner set and nothing else changed. A site neither source can name is left as it is and logged by id; that one needs an operator to re-run createSite on its id as the person who should own it. A second run selects nothing.

Writes need rank 200 and above ({admin, developer, owner} under the one ladder). A client's own domain and a CI-pushed source are cluster-owner acts and are offered to nobody else. A source that ships MemQL DSL needs an actor who may author constructs -- an owner or a developer -- and is refused with dsl_requires_authoring at the start of the run; see who may deploy what.


The hostname policy

A user's site is <slug>.<domain>, where <domain> is the one this cluster serves (derived from MEMQL_DOMAIN through component/frontdoor, the same derivation the OS host uses).

  • The slug matches [a-z0-9-]{3,40}.
  • It must be a single label -- one label, because the front door's *.<domain> Ingress wildcard matches exactly one.
  • It must be unique across sites.
  • It must not be reserved.

The reserved set is derived, not listed. It is frontdoor.Roles() (api, identity, mcp) plus frontdoor.PlatformSites() (os, vscode), plus the six held labels in squatReservedSiteLabels (component/memql/platform_site_hostname_policy.go). Deriving it means a new role or platform site can never become claimable by forgetting to add it here. The held six are different in kind. www, admin, mail and portal are served by nothing, and that is precisely why they are held -- they are the labels a person reads as the organization's rather than a tenant's, and mail is where a mail host would land if one is ever added. voice and turn are the LiveKit signaling and TURN hosts of Ask and voice.

Any other hostname -- a custom apex, a different domain -- stays cluster-owner-only, as before -- but no longer hand-certified. A client's own domain is bound through the custom-domain flow, reachable both as the ownDomain half of a placement and on the deployable's Where-it-lives stop afterwards: it shows the two DNS records to create, the cluster verifies them and says which one is still wrong, and the exact-host Ingress and Certificate are provisioned once both check out (epic memql#4805, front-door.md).


Kinds

Three kinds are live, and the enum holds exactly these three:

KindWhat it is
spaA single-page app bundle.
staticA plain website.
shopify_storefrontA SPA bundle with a typed binding to a Shopify store.

Android, iOS and macOS have no schema at all, and now have a written-down target shape instead (above) rather than a client-side "coming soon" list. That is deliberate: those are artifact distribution (stores, TestFlight, notarisation), not hostname-resolved web surfaces, and an enum value that never resolves would be the wrong kind of additive.


Deploying a zip from the Library

sitePublishFromArtifact(siteId, artifactId) publishes a zip artifact from the person's Library as the site's new bundle -- the hand-made path's Deploy, and the Redeploy behind it afterwards. It runs entirely under the caller's actor: the site, the artifact and the backing file are each read through owner-scoped queries, so a cross-user call returns zero rows and is refused by name rather than by a special case.

The bundle is validated before anything is written: it must be a zip, it must carry index.html at the ROOT for spa and shopify_storefront, and it is held to the same per-file (25 MB), total (500 MB) and file-count (20000) limits the CI bundle route enforces. Path traversal is refused, not sanitised.

Atomicity is the publisher's: a new content-addressed version is written first, and only then does the row's bundleRef flip. artifactId is stamped on the row so a site can say which bundle it came from.

Every refusal carries a stable machine-readable reason rather than prose, so the surface can explain it and an audit row can be searched on: site_not_found, artifact_not_found, artifact_archived, artifact_not_a_file, file_not_found, file_archived, artifact_not_a_zip, storage_not_configured, bundle_unreadable, bundle_not_a_zip, bundle_path_invalid, bundle_too_many_files, bundle_file_too_large, bundle_too_large, bundle_empty, bundle_missing_index, publish_failed, missing_argument.

Success and refusal are both audited to v1:identity:auditEvent (action: site_publish_from_artifact), best-effort -- an audit write that fails must never report a completed deploy as failed.

INFO: POST /sites/{id}/bundles is unchanged. It remains the CI door, for service accounts only. No user-facing bundle upload route was added; a browser deploys from bytes the system already holds.

A new deployable starts with a placeholder bundle. createSite requires bundleRef and the schema has no empty state, so a draft is created holding blob://sites/<siteId>/pending/ with status: "draft" -- the placeholder convention site-hosting.md already describes. That is safe because a draft site is refused before any file lookup happens, so the placeholder is never dereferenced, and it is what lets a CI-pushed source be a source rather than a special case. The first real publish replaces it, and a draft still holding the placeholder is not a publish anybody made.

Rollback is unchanged: the Live stop walks the site's history with an asOf read and sets bundleRef back. Old versions stay under their own content-addressed prefixes, so restoring one serves its original content. For a package deployable, a whole-package rollback lives under Every attempt and restores a prior run's recorded tuple.


The Shopify storefront

A shopify_storefront site is a SPA that MemQL hosts at <slug>.<domain>, with a typed binding to a store. Checkout is Shopify's own hosted checkout.

The binding is {storeDomain, storefrontTokenRef}, where storefrontTokenRef names a v1:platform:globalSecret row. The token itself is never stored on the site row. This is the one named-cluster-secret reference a deployable still carries, and it is a cluster-level integration credential rather than a personal one -- which is exactly the distinction a source credential exists to draw.

At serve time the edge injects {kind, storeDomain, storefrontToken} into the site's runtime-config document -- the same mechanism that already gives the the console its config -- resolving the token from the named secret then, not at publish time.

Two properties of that are load-bearing:

  • The KIND is the gate, not the presence of a binding. A binding left on a spa row consults the secret store zero times.
  • The Admin token is never injected. A Storefront API token is a client-side credential by Shopify's design; an Admin token is not, and a test greps the served document to keep it that way. An unresolvable token yields an empty value, never an error string in the document.

A storefront also gets the SPA fallback, because it is a SPA bundle -- without it, reloading a deep link like /products/x would 404.

The binding is read-only after creation. There is no updateSiteBinding mutation -- binding is written by createSite, and changed by re-running it on the same id or by the package's manifest at the next deploy -- so the surface renders the binding rather than offering a field that would silently do nothing.

Orders, inventory and refunds are not this feature. They reach MemQL through webhooks and reconciliation, so a sale from any channel lands, not only one made through this storefront (the Shopify connector).


TLS

A freshly deployed site is live over TLS with no operator step, provided the cloud overlay declares the DNS-01 issuer: a wildcard Certificate for *.<domain> covers every site hostname, and the edge Ingress's wildcard rule carries it.

Without that issuer the pre-existing rule stands -- the front-door certificate names exact hosts only, and a site routed by the wildcard terminates TLS with the ingress controller's self-signed default until it has a Certificate and an exact-host Ingress of its own.

See front-door.md for both regimes and azure-entry-install.md for the DNS-01 prerequisites.

WARNING: locally the mkcert pair IS a wildcard. A site that works over https on the local cluster is no evidence that it has a certificate in the cloud.


Runtime settings

A deployable carries settings: key-values its bundle reads when it loads, served in the runtime-config document the edge already answers with (GET /runtime-config.json). A bundle reads one as config.settings.apiBase.

The point is that one bundle can serve two deployables against different endpoints with no rebuild. Publish the same bytes to eu.<domain> and us.<domain>, give each its own apiBase, and each reads its own.

Set them on a deployable's Live stop in MemQL OS, or write them directly:

text
mutation updateSiteSettings(siteId: "v1:platform:site:abc", settings: {apiBase: "https://api.eu.example.com", region: "eu"})

The write REPLACES rather than merges. The whole object is the argument, which is what makes removing a setting expressible -- a merge would make every "delete this one" silently re-save the value already there. The OS editor sends the map it shows for the same reason.

Not a place for a secret

Every value here is served to every visitor, unauthenticated, in a document their browser fetches. A value in settings is public by construction.

A key ending in Ref is refused, and that refusal is the reason worth knowing. ...Ref is already this platform's convention for a value that must NOT be public: the storefront binding's storefrontTokenRef NAMES a v1:platform:globalSecret row that the edge resolves at serve time for exactly one site kind. A settings key spelled that way would look like that convention and be honoured by nothing -- the edge serves the string as typed -- so the natural mistake would publish a secret's name, and the natural next mistake would be teaching the edge to resolve it. Put a credential in the cluster's secrets and name it from the deployable's binding.

The limits

RuleValue
Key form[A-Za-z][A-Za-z0-9_]{0,63} -- what a bundle can read as config.settings.<key>
Keys per deployableMEMQL_SITE_SETTINGS_MAX_KEYS, default 64
Characters per valueMEMQL_SITE_SETTINGS_MAX_VALUE_LENGTH, default 2048
Value typea plain string; anything else is refused
System-owned rowsrefused, for a cluster owner too

The caps are on the row because the document is served on every page load and grows with it. A system-owned deployable (MemQL OS, the VS Code landing page) refuses the write whoever asks: those rows are re-seeded at every boot, so a value set on one would be reverted and would look like it had worked until then.

Enforced beside the engine's write path (component/memql/platform_site_settings_guard.go), not in the mutation body: a mutation sees a value and never an object's KEYS.


Traffic and health

Is anybody using this deployable, and is it healthy. Both are read from what the edge actually served, never from a guess.

What is measured. The edge writes one row per served request into its own edge_request log -- the deployable it resolved to, the status it answered, the bytes, how long it took, and what it did with the request (an asset, an HTML document, the SPA fallback, a proxied call, the runtime-config document, or nothing at all for a deployable that is paused or in draft). TimescaleDB folds those rows into per-minute and per-hour aggregates. The figure and the raw log cannot disagree, because the figure IS the log, folded.

What is not measured. Nothing that identifies a visitor: no address, no user agent, no path, no referrer. The question this exists to answer needs counts and outcomes and nothing about who.

The buckets. A window of an hour reads minute buckets; a day or a week reads hour buckets, because a week of minute buckets is ten thousand rows to draw one line. Both are kept for MEMQL_EDGE_REQUEST_LOG_RETENTION_DAYS (default 30, clamped 1..365) -- the raw rows and both aggregates on the same schedule, so "unmeasured" means the same thing at every horizon.

Unmeasured is not zero. A window with no rows answers unmeasured and says so in words; a window with requests and no errors answers zero errors. Read them as different facts: one means nobody visited, the other means nothing was recording, and they send you to different places. Three things make a window unmeasured:

  • nobody visited;
  • MEMQL_EDGE_REQUEST_LOG_ENABLED is false on the replica that served (the aggregate is then short by that replica's share);
  • the deployable is system-owned. MemQL OS and the VS Code landing page are excluded by construction, so they are always unmeasured -- measuring the console somebody reads a figure in would be measuring the act of looking.

Errors and not-found are counted apart. 5xx is the deployable failing; 4xx is somebody asking for a page it does not have. Folding them together makes a healthy site with a broken inbound link look unhealthy.

Who can read it. Whoever can read the deployable: the read resolves each id through siteById / sitesAll under the caller's own actor, so a deployable you cannot read contributes no rows -- the same answer one with no traffic gives, which is what keeps the call from telling you whether somebody else's id exists.

The cost to a visitor. None that is measurable. The write is a non-blocking hand-off to a batching writer that drops and counts under pressure rather than making anybody wait on Postgres; serving a bundle asset measured 3139-3202 ns/op without the log and 3000-3188 ns/op with it, at one extra 32-byte allocation. Drops are on memql_site_traffic_dropped_total (labelled queue_full / write_failed) and writes on memql_site_traffic_written_total -- read them together when a figure looks low, because the figure itself cannot say what is missing from it.

Read it on a deployable's Live stop in MemQL OS, or directly:

text
builtin siteTrafficInWindow(siteIds: ["v1:platform:site:abc"], bucket: "1h", windowStart: "2026-09-02T00:00:00Z", windowEnd: "2026-09-03T00:00:00Z")

summary: true folds the window into one row per deployable instead of one per bucket -- what a list needs to say "last served" for twenty of them without pulling twenty series. At most 200 deployables per call, refused past it rather than truncated: a silently short answer would read as "those have no traffic".

What it is not

No alerting, no uptime probing from outside the cluster, no per-path analytics and no geographic breakdown. Each is out of scope by decision rather than by omission.


Where the surface is

MemQL OS, the Deployables app, in three sections: Map (what serves where, and the window's default), Deployables (the list, and the page that composes, deploys and manages one), and Settings (the app's own preferences, plus the Sources group above). Composing is not a fourth section and not a modal: New deployable opens the same page in compose mode, in place, with a quiet Back to the list.

A package with two apps is two rows sharing a source, grouped under it -- one row per thing that serves or will.

The Deployables app is the one surface, and /sites and everything under it redirect to it, tail included, so an old bookmark or a link in a runbook still lands.


  • Packages and deployables -- the source half: the manifest, the pipeline, DSL delivery, and the refusal catalogue.
  • The Library -- where the zip artifact you deploy comes from.
  • Site hosting -- the request path, the CI publish route and the per-site CSP.
  • Front door -- hosts, certificates, custom domains and the two TLS regimes.
  • GitHub Connect -- connect GitHub once and pick a repository from a list; the pasted token stays behind "Use a token instead".