← All docs
Guide

Features

The customer-facing capabilities, organized by what they let you do. Each feature explains:

Features

The customer-facing capabilities, organized by what they let you do. Each feature explains:


Releases, Deployments, Reviews

The deploy story.

What

Why it works

The graph is bitemporal append-only. Releases are immutable after assembly. Deployments are append-only facts. Combined, that's "every state of every site at every moment is queryable."

Lifecycle

draft → pending → approved → deployed
   ↘ archived  ↘ archived  ↘ archived | superseded

State transitions enforced by RELEASE_TRANSITIONS in release-service.ts. Deploy gate (ADR-0021) checks:

If any check fails, POST /api/releases/:id/deploy returns 409 with the failure reason.

How to use

Via UI: Releases tab → New release → add includes → Submit for review → Approve → Deploy.

Via API:

POST /api/releases                       → create draft
POST /api/releases/:id/includes          → add a Versionable
POST /api/releases/:id/transition        → state machine
POST /api/releases/:id/deploy            → create Deployment (gated by reviews)

See api.md → Releases for full request/response shapes.

Not in scope


Drip publishing

Schedule N approved Releases across a time window with smart cadence.

What

Take 12 approved Releases and 8 hours, get 12 Deployments staggered across the window. Three patterns:

Plus optional minSpacingMs — guarantees at least N ms between any two consecutive timestamps.

Why it works

Drip is N Releases with staggered validFrom. Same primitive as a regular deploy. The scheduler fires each Deployment when its validFrom arrives. Reviews still gate. Audit still works. Rollback still works per Release.

Preview-then-approve

The scheduler is deterministic — same input + same seed = same timestamps. So the customer flow is:

  1. POST /api/releases/drip-preview — see the calendar
  2. Operator reviews the timestamps
  3. POST /api/releases/drip-schedule — same body + environmentId, commits

If the customer doesn't like the calendar, they tweak inputs (different seed, narrower window, different pattern) and preview again. Nothing is written until step 3.

How to use

# Preview
curl -X POST .../api/releases/drip-preview \
  -d '{
    "releaseIds": ["release:1", ..., "release:12"],
    "windowStart": "2026-05-01T08:00:00.000Z",
    "windowEnd":   "2026-05-01T16:00:00.000Z",
    "pattern":     "jittered",
    "maxJitterMs":  600000,
    "seed":         42
  }'
# → { schedule: { timestamps: [...] }, pairs: [{releaseId, validFrom}, ...] }

# Approve
curl -X POST .../api/releases/drip-schedule \
  -d '{ ...same body, "environmentId": "env:prod" }'

Browse the queue any time:

curl ".../api/releases/queue?envId=env:prod&limit=100"
# → { queue: [{ id, releaseId, validFrom, status }, ...] } ordered by validFrom asc

Not in scope


A/B testing

Test variants of a page without adding runtime rendering.

What

A Release can carry multiple weighted variants for a path. The L@E resolver runs selectVariant() per request — sticky-by-cookie, deterministic, independent across paths. Variants are versioned artifacts in the manifest layer.

Why it works

A/B is a Release with multiple pointers. The manifest entry shape changed from:

{ "/landing/": { "hash": "abc...", "ext": "html", "size": 1200 } }

to optionally:

{
  "/landing/": {
    "variants": [
      { "key": "A", "hash": "abc...", "ext": "html", "size": 1200, "weight": 0.5 },
      { "key": "B", "hash": "def...", "ext": "html", "size": 1300, "weight": 0.5 }
    ]
  }
}

The resolver detects via 'variants' in entry and runs selection.

Selection algorithm

selectVariant({ path, variants, bucketKey, existingVariantCookie })
  1. If a sticky cookie matches a current variant key → return that variant (no fresh assignment, no new cookie)
  2. Otherwise hash ${bucketKey}|${path} with xxhash32 → pick by cumulative weight → set sticky cookie

Properties:

Why no runtime rendering?

The resolver routes between already-built artifacts. Both A and B were compiled at deploy time. The page itself is still 100% static HTML. We don't render at request time; we just pick which prebuilt thing to serve.

That's the wedge: most A/B platforms add a runtime layer. We extend the existing pointer model.

Audit-grade A/B

For regulated customers: Replay can answer "what variant did this user see at 3:14pm last Tuesday?" The variants are versioned artifacts. The selection rule was a graph fact at the time. The cookie-to-variant assignment was deterministic. Re-running the selection function against the past manifest reconstructs exactly what was served.

Most personalization platforms can't answer that question because their rendering was ephemeral.

How to use

Today the data shape ships in @staticowl/core/build/manifest.ts (ManifestEntry.variants, selectVariant(), normalizeVariants(), variantCookieName()). Tests in manifest-variants.test.ts cover determinism, weight distribution at scale, sticky-cookie behavior, cross-path independence.

What's not yet built:

Phase 2 work; data model is stable.

Not in scope


Multi-output

The same compiler emits web pages, social posts, flyers, signs, QR codes, emails, and ad creative — all from the same content.

What

A Template declares its outputKind (web / social_post / flyer / sign / qr_code / email / ad_creative / transcript). The build pipeline dispatches per kind. One Release can include templates of different kinds → one Release emits artifacts for every channel.

Why it works

Same Release, same Versionables, same audit chain. The compiler routes by template kind to the right per-kind compiler. Print-bound kinds get higher DPI + CMYK consideration; social kinds get aspect-ratio matrices; QR kinds get generateQR().

The output-kind registry

packages/core/src/output/output-kinds.ts exports a frozen registry:

{
  web:         { defaultDpi: 72,  printBound: false, formats: ['html'], aspectRatios: [], complianceTracked: true },
  social_post: { defaultDpi: 72,  printBound: false, formats: ['png','jpeg'], aspectRatios: ['1:1','4:5','9:16','16:9','1.91:1'], complianceTracked: true },
  flyer:       { defaultDpi: 300, printBound: true,  formats: ['pdf','png'], aspectRatios: ['8.5:11','8.5:14','11:17','210:297'], complianceTracked: true },
  sign:        { defaultDpi: 150, printBound: true,  formats: ['png','pdf'], aspectRatios: [], complianceTracked: true },
  qr_code:     { defaultDpi: 300, printBound: false, formats: ['png','svg'], aspectRatios: ['1:1'], complianceTracked: false },
  email:       { defaultDpi: 72,  printBound: false, formats: ['html'], aspectRatios: [], complianceTracked: true },
  ad_creative: { defaultDpi: 72,  printBound: false, formats: ['png','jpeg'], aspectRatios: ['1:1','16:9','4:1','300:250','728:90','160:600'], complianceTracked: true },
  transcript:  { defaultDpi: 72,  printBound: false, formats: ['txt','html'], aspectRatios: [], complianceTracked: false },
}

QR generator

generateQR(data, opts) is shipping today. Pure JS, deterministic, scannable. Used by templates, lifecycle hooks, or any consumer needing a QR. See api.md and packages/core/src/output/qr-generator.ts.

import { generateQR } from '@staticowl/core';

const qr = generateQR('https://example.com', {
  errorCorrection: 'M',
  modulePixels: 10,
  fg: '#000000',  // hex or named-color allowlist
  bg: '#FFFFFF',
});
// → { svg, matrix, moduleCount, sideLength, data }

The killer use case

"Show me every public-facing asset derived from Product X during the recall window."

Most CMSes punt on this — content lives in their database, but social posts live in Buffer, ads in Meta, signs in someone's local Figma. There's no single source of truth across channels.

In our model: every output artifact is a Versionable in the graph, bundled into a Release, attached to the source content. The recall query is a single Cypher traversal. "What flyer was in circulation in Q2?" is two seconds, full audit chain, end to end.

That's the wedge in pharma (recalls, drug-info changes), insurance (jurisdiction-specific terms), legal (consent variants), retail (SKU discontinuation across channels).

Status

Not in scope


GitHub mirror

Push the compiled site plus per-page JSON sidecars + global metadata to a Git repo the customer owns. One commit per deploy. Push-only.

What

Set STATICOWL_DEPLOY_TARGET=github and supply credentials; every Release fires a Git commit to your repo with:

/index.html
/blog/post-1/index.html
/_meta/pages/index.json            ← sidecar JSON per page
/_meta/pages/blog/post-1.json
/_meta/types.json                  ← content-type definitions
/_meta/theme.json                  ← site theme
/_meta/routes.json                 ← route map (when present)
/_meta/release.json                ← this deploy's facts

Why it matters

How to use

See deploy-targets.md → github for env vars + token rotation.

Not in scope


Replay & audit

The canonical query: Site(env, time) returns the exact bytes that were live at that point in time.

What

At 2:14pm on January 15, what HTML was at https://example.com/blog/launch/? Two seconds. Includes:

Everything coherent. No "we have the content but the templates have moved on."

Why it works

Every Versionable is bitemporal. Every Release is immutable. Every Deployment carries validFrom. The replay query:

  1. Finds the active Deployment for (envId, atTime) — the one with status='deployed' AND validFrom <= atTime, latest first
  2. Reads each INCLUDES edge → (versionableId, version, at)
  3. For each Versionable, queries the engine at the bundled at timestamp using bitemporal MATCH (n) AT '<iso>'
  4. Renders normally

The result is byte-identical to what was live then because the underlying graph never overwrote anything.

Cryptographic audit chain

The Graphiquity engine layer maintains a hash chain over every mutation. Each transaction's hash is computed from the prior transaction's hash + the new mutations. Any tamper anywhere in the chain invalidates the chain root. You can prove the version of any content node at any past instant — and prove that nobody (including admins) tampered with the trail.

How to use

GET /api/releases/replay?envId=env:prod&atTime=2026-01-15T14:14:00.000Z
# → { deployment, release, includes }

The admin UI's Replay tab consumes this endpoint and renders the historical site in an iframe.

Compliance claims this enables

Rollback

Rollback is a query. Not "restore from backup." Not "redeploy old code." It's a new Deployment fact pointing at an older Release. Atomic at the CDN layer (single S3 pointer write). Audit-stamped (creates a fact). Reversible with the same operation in reverse.

POST /api/releases/deployments/:id/rollback
{ "rollbackToReleaseId": "release:..." }

Not in scope


Reviews (deep dive)

Reviews gate deploys.

Kinds

Findings + severity

Each Review can attach findings with severity info / warning / blocking. Blocking findings cause the deploy gate to return 409. Non-blocking findings are surfaced but don't block.

Stale review detection

Reviews carry a dependency hash computed from the versions of the Versionables they reviewed. If any of those Versionables changes after the Review passed, the review is "stale" — the deploy gate refuses to proceed and asks for a re-review.

This is what catches "I approved version 3, but version 4 went to deploy" without needing to re-run every Review on every commit.

Auto-Reviews on agent writes

Planned: writes via agent-scoped tokens auto-attach an ai-kind Review with status='pending'. The deploy gate then requires human override or human approval before deploy. Agents can't ship to prod without a human nod.


See also