Orchestrator-safe writes

Embed
Preview of the card
<a href="https://forumulate.com/guides/orchestrator-safe-writes"><img src="https://forumulate.com/guides/_api/public/orgs/046a9bc0-d530-43ae-9bae-af7945008cda/spaces/36aa4df0-1e95-4cef-8ae9-795169441345/pages/f41a8c2b-8b6b-49c7-8b8b-9f70d6bd36ae/social-card.svg?v=3e7ae33eec8b" alt="Orchestrator-safe writes" width="600" height="315"></a>

A provisioning tool, a CI job or an agent can retry a request whose first answer it never saw. The API is built so that retry is safe. A write can be made idempotent, rehearsed without side effects, and its shown-once secret sealed for a single recipient. A sandbox organization can be removed outright once its run is over.

Provision the whole organization: the manifest

Rather than calling each create in turn, a provisioning run can send one document: PUT /orgs/{orgId}/manifest. It is Forumulate's own provisioning contract — drive it from a script, or the owner's own tooling.

  • Units. Besides spaces (themes, redirects, collections, categories), a "version": 2 manifest declares domains, reader_auth, consent, connectors, executor, webhooks, status_monitors and brand, naming spaces and sites by slug. These block names are exactly as the API takes them: reader_auth and status_monitors are the two snake_case keys in an otherwise camelCase document (readerAuth is a different key, the one-word shorthand readerAuth: atlas). Every key is listed in the version 2 reference.

  • Plan, then apply. Without ?apply=true the answer is a plan: steps of {unit, ref, action, diff, requires, id}, where id is the resource the step would change (or null for a create). With ?apply=true each step answers {unit, ref, id, action, status, readBack}, the apply runs as a job (job, then GET /orgs/{orgId}/manifest/runs/{runId}), and what only the customer can do (DNS records) is listed under manual.

  • Re-runs are safe. Each unit's resource is recorded as it lands, so a run killed half-way and started again creates nothing twice.

  • Adopt what exists. GET /orgs/{orgId}/manifest?export=true answers the organization as it is now, as a version: 2 manifest with every id. Applying it unchanged is a no-op that records those ids.

External references

The creates an orchestrator runs most take externalRef: organizations, sites, spaces, domains, webhooks, personal access tokens, integration tokens, connectors, status monitors and the privacy executor key. It is your own name for the thing. The older spelling metadata.forgedeck_ref is still accepted as the same field, so a run written against it keeps working.

The first create with a given externalRef makes the resource and answers 201 as usual. A repeat finds that resource and answers 200 with idempotent: true. Nothing is duplicated, and a secret shown at creation is not shown again (the field is null). Two creates racing on one reference both answer the same resource, because the loser discards its own row.

Lost the secret of a resource you made this way? Rotate it. For a personal access token that is POST /pats/{id}/rotate, with the id from the 200 answer. The rotated token keeps its externalRef and tags, so the next run finds it again rather than minting a second one. See get-or-create with externalRef.

Idempotency-Key

Any authenticated write also takes an Idempotency-Key header. Use a fresh value (a UUID) for each logical request, and send the same value when you retry it.

  • The first request with a key runs. Its 2xx answer is stored for 24 hours, scoped to you (the user or token that sent it), encrypted at rest.

  • A repeat of that request with the same body replays the stored status and body, with Idempotent-Replayed: true, and writes nothing.

  • A repeat while the first is still running is 409 idempotency.in_flight. Wait a moment and send the same request again.

  • The same key with a different body is 409 idempotency.key_reused: a key names one request, not a session.

A refused or failed attempt (4xx, 5xx) is not stored. Fix the request and retry it under the same key.

Dry runs

?dryRun=true works on every authenticated write that has no dry run of its own. The request is authenticated, authorized and validated exactly as it would be, then answers 200 and writes nothing. A 422 on a dry run is the 422 the real write would get.

"Dry run" is spelled five ways, and they do not all mean the same thing:

Where

Spelling

The default

What a dry run answers

Any authenticated write without its own

?dryRun=true

writes

200 once the request is authorized and valid; nothing written. Generally no diff — a write that computes its own preview (the redirect table, a site replace) says so in its reference entry.

The manifest, PUT /orgs/{orgId}/manifest

?apply=true to write

dry run

The per-unit plan: steps of {unit, ref, action, diff, requires, id}.

forumulate plan / forumulate apply

the command name

plan writes nothing

The same plan, from a version: 2 forumulate.yaml; plan --check exits 1 on drift.

forumulate publish

--dry-run

writes

The plan for the folder (create, update, move, prune), and nothing sent. Against a public space it needs no token.

forumulate sync, and a connected repository's POST …/github/sync

--apply; {"dryRun": false}

dry run

The plan per piece; --diff adds the change inside each. --dry-run --apply together is refused.

forumulate api <Operation> and the resource commands

--dry-run

sends

The request the CLI would send — method, URL, headers, body — printed, not sent. Where the operation has a dry run of its own it is passed through instead.

Rollback

There is no single undo; each kind of thing has its own way back. All of them are ordinary writes, so they take Idempotency-Key and ?dryRun=true like any other.

What changed

Way back

Operation

A page's published text

Republish an earlier revision (list them with Revision_history, compare with Revision_diff)

Revision_rollback — POST …/pages/{pageId}/rollback { "revisionId": … }, needs docs.publish

A page's draft

Restore a named checkpoint (Revision_checkpoints lists them)

Revision_restoreCheckpoint — POST …/pages/{pageId}/checkpoints/{checkpointId}/restore

A deleted page

Take it out of the trash (Page_trash lists it)

Page_restore — POST …/pages/{pageId}/restore

A whole docs space

Restore a snapshot you took earlier (SpaceSnapshot_create); the answer lists pages addedSince, and newPages: "unpublish" takes those out of the reader

SpaceSnapshot_restore — POST …/snapshots/{id}/restore

A deleted space

Take it out of the trash

Space_restore — POST …/spaces/{spaceId}/restore

A blog post, forum topic or release you deleted

Recover it

Blog_recoverArticle, BlogComments_recoverPost, ForumTools_recover

A content-library entry

Restore one of its revisions; a trashed entry from the library's trash

EntryCard_restore, LibraryItem_restore

A content release

Roll the release back

Releases_rollback — POST /orgs/{orgId}/content/releases/{releaseId}/rollback

A space's or site's theme draft

Discard the draft; the live theme is untouched until a draft is published

SpaceSettings_discardThemeDraft, ThemeBundle_discardSiteDraft

The whole organization

Restore a backup: brings back what is missing, deletes nothing made since

OrgBackup_restore — POST /orgs/{orgId}/backups/{backupId}/restore { "confirm": <org slug> }, owners only

Site config, a live theme, SEO settings and manifest applies keep no history of their own. To be able to go back, read the current state first — GET /orgs/{orgId}/manifest?export=true answers the organization as a version: 2 manifest — keep that file, and apply it again to return.

Sealed secrets

Send X-Secret-Recipient: x25519:<base64 public key> on a write that mints a shown-once secret (a token, a webhook signing secret, an executor key, a connector token, a preview link). The answer then carries the secret only as sealed_secret, a libsodium sealed box that only the holder of the matching private key can open, so it never sits in a log in plain text. A malformed key is a 422 and nothing is minted.

Metadata tags

metadata is a flat map of your own tags: up to 50 keys of letters, digits and _ . : -, each a string (up to 500 characters), number, boolean or null. Set it at create. The lists of organizations, spaces, sites, domains, webhooks, tokens, connectors and status monitors filter on it with ?metadata[env]=staging. A malformed filter is a 422, never a filter that silently matched everything.

To change the tags later, send metadata in a PATCH. It replaces the tags whole, {} clears them, and the externalRef stays as it was set at create:

Resource

Request

Organization

PATCH /orgs/{orgId}

Space

PATCH /orgs/{orgId}/spaces/{spaceId}

Site

PATCH /orgs/{orgId}/sites/{siteId} (honours ?dryRun=true)

Domain

PATCH /orgs/{orgId}/spaces/{spaceId}/domains/{domainId}, …/sites/{siteId}/domains/{domainId} or …/content/libraries/{libraryId}/domains/{domainId}

Webhook

PATCH /orgs/{orgId}/webhooks/{webhookId}

Connector

PATCH /orgs/{orgId}/spaces/{spaceId}/connectors/{connectorId}

Status monitor

PATCH /orgs/{orgId}/spaces/{spaceId}/status/monitors/{monitorId}

Personal access token

PATCH /pats/{id} (with name, or alone)

Integration token

PATCH /orgs/{orgId}/integration-tokens/{id} (metadata only)

A PATCH that sends only metadata changes nothing else. On a space it does not fire space.updated. A domain is re-tagged only through the space, site or library it is attached to; any other path is a 404. The domain route takes metadata and nothing else, because a domain's other settings have their own routes (www, redirect, ip-allow-list). On the two token routes any other field is a 422. Tags are labels, and a token's grants stay as they were minted. A revoked integration token is a 404.

Sandbox delete

An organization created for a test run can be removed for good:

DELETE /orgs/{orgId}?sandbox=true&confirm=<the org's slug>
  • The organization must be tagged a sandbox (metadata.sandbox: true, or the older alias metadata.forgedeck_sandbox: true, set at POST /orgs). Otherwise the answer is 409 sandbox.not_tagged. Use DELETE /orgs/{orgId} without sandbox for the ordinary, recoverable delete.

  • confirm must be the slug. Otherwise the answer is 422 validation.failed with reason: confirm_required.

  • A reference that does not cascade is 409 sandbox.delete_blocked, naming the constraint.

Each refusal changes nothing. A delete that goes through removes the organization and everything in it. It cannot be undone, and it is recorded outside the organization (its own audit log goes with it), with the actor, the token and the request's trace id.

In the API

Every operation below is also reachable from the other surfaces without a dedicated command. forumulate api <Operation> calls it from the CLI, the JavaScript SDK has a typed method for each one (the last column names it; the Python and Go SDKs are generated from the same spec), and an agent reaches the organization-scoped ones through the MCP agent server — its api_* family tools, or call_api. Where a CLI command or a hand-made MCP tool exists too, the last column names it. Operation names link to the API reference.

Domain Tags

Operation

Request

What it does

Shortcuts

DomainTags_spaceDomain

PATCH /orgs/{orgId}/spaces/{spaceId}/domains/{domainId}

Replace a space domain’s tags

client.domains.tags.spaceDomain()

DomainTags_libraryDomain

PATCH /orgs/{orgId}/content/libraries/{libraryId}/domains/{domainId}

Replace a content library domain’s tags

client.domains.tags.libraryDomain()

DomainTags_siteDomain

PATCH /orgs/{orgId}/sites/{siteId}/domains/{domainId}

Replace a site domain’s tags

client.domains.tags.siteDomain()

Manifest

Operation

Request

What it does

Shortcuts

Manifest_export

GET /orgs/{orgId}/manifest

Export the organization as a manifest (adopt)

forumulate manifest export<br>client.orgs.manifest.export()

Manifest_apply

PUT /orgs/{orgId}/manifest

Apply a declarative site manifest (dry run by default)

forumulate manifest plan<br>forumulate manifest apply<br>client.orgs.manifest.apply()

Manifest_runStatus

GET /orgs/{orgId}/manifest/runs/{runId}

Get a manifest apply run

forumulate manifest run<br>client.orgs.manifest.runStatus()

Releases

Operation

Request

What it does

Shortcuts

Releases_list

GET /orgs/{orgId}/content/releases

List releases

forumulate releases list<br>client.content.releases.list()

Releases_create

POST /orgs/{orgId}/content/releases

Create a release

forumulate releases create<br>client.content.releases.create()

Releases_candidates

GET /orgs/{orgId}/content/releases/candidates

Find drafts to add to a release

forumulate releases candidates<br>client.content.releases.candidates()

Releases_get

GET /orgs/{orgId}/content/releases/{releaseId}

Get a release

forumulate releases get<br>client.content.releases.get()

Releases_update

PATCH /orgs/{orgId}/content/releases/{releaseId}

Rename a release

forumulate releases rename<br>client.content.releases.update()

Releases_remove

DELETE /orgs/{orgId}/content/releases/{releaseId}

Delete a release

forumulate releases delete<br>client.content.releases.remove()

Releases_addItem

POST /orgs/{orgId}/content/releases/{releaseId}/items

Add a draft to a release

forumulate releases add<br>client.content.releases.addItem()

Releases_removeItem

DELETE /orgs/{orgId}/content/releases/{releaseId}/items/{itemId}

Remove an item from a release

forumulate releases remove<br>client.content.releases.removeItem()

Releases_schedule

POST /orgs/{orgId}/content/releases/{releaseId}/schedule

Schedule a release

forumulate releases schedule<br>client.content.releases.schedule()

Releases_unschedule

POST /orgs/{orgId}/content/releases/{releaseId}/unschedule

Unschedule a release

forumulate releases unschedule<br>client.content.releases.unschedule()

Releases_publish

POST /orgs/{orgId}/content/releases/{releaseId}/publish

Publish a release now

forumulate releases publish<br>client.content.releases.publish()

Releases_rollback

POST /orgs/{orgId}/content/releases/{releaseId}/rollback

Roll a release back

forumulate releases rollback<br>client.content.releases.rollback()

Releases_preview

POST /orgs/{orgId}/content/releases/{releaseId}/preview

Preview a release

forumulate releases preview<br>client.content.releases.preview()

The Forumulate teamUpdated