Features
The customer-facing capabilities, organized by what they let you do. Each feature explains:
- What it is in one sentence
- Why it works (the architectural primitive it's built on)
- How to use it (UI / API path)
- What's NOT in scope
Releases, Deployments, Reviews
The deploy story.
What
- A Release is an immutable bundle of Versionable versions (content + templates + types + routes + theme + settings).
- A Deployment is a fact pointing a Release at an environment with a
validFromtimestamp. - A Review is an approval/finding gate attached to a Release. Kinds:
ai,automated,manual,compliance. Blocking findings prevent deploy with HTTP 409.
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:
- Every required Review has passed
- No Reviews have stale dependency hashes
- No blocking findings exist
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
- No partial deploys — a Release is all-or-nothing. If you want to ship just one article, create a Release containing that one article.
- No "merge two Releases" — Releases are immutable. Create a new Release derived from two parents.
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:
even— equally-spaced. Predictable. "One post per hour."random— uniform-random points, sorted. Looks organic. Can cluster.jittered— even spacing + small random offset. The compromise: predictable cadence but avoids "exact 30-minute marks" 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:
POST /api/releases/drip-preview— see the calendar- Operator reviews the timestamps
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
- No "batch-create Releases from N content items" — customer creates the Releases first, then schedules them. Cleaner separation.
- No timezone awareness in the API — pass UTC
validFromtimestamps; the UI can render in local time.
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 })
- If a sticky cookie matches a current variant key → return that variant (no fresh assignment, no new cookie)
- Otherwise hash
${bucketKey}|${path}with xxhash32 → pick by cumulative weight → set sticky cookie
Properties:
- Deterministic: same
(bucketKey, path)→ same variant - Independent across paths: same visitor on
/landing/vs/checkout/can land on different variants - Drop-in for L@E or CloudFront Function: pure JS, no Node deps
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:
- Admin UI to author variants
- API endpoint to upload an A/B Release
- Lambda@Edge resolver (manifest pipeline ships ahead of L@E provisioning)
Phase 2 work; data model is stable.
Not in scope
- Multi-armed bandits / Bayesian optimization — we serve weighted variants, not adaptive ones
- Visual WYSIWYG variant editing — author HTML/template variants like any other content
- Per-segment targeting based on declared user attributes — see "personalization" discussion in architecture.md — Phase 3+
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
- Web — shipping. Every Release today emits a website.
- QR codes — engine support shipped:
generateQR(), deterministic, scannable, deployable to lifecycle hooks and templates. - Output-kind registry — shipped:
OUTPUT_KIND_REGISTRYenumerates every channel. - Social, flyer, sign, email, ad — Phase 2: per-kind compilers, brand-aware export, admin UI for channel selection.
Not in scope
- A freeform design canvas (that's Canva's job; we lose that fight)
- User-uploaded arbitrary print designs (no graph backing, no audit trail)
- Real-time client-side personalization for output artifacts
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
- Anti-lock-in proof:
git clone+ the_meta/*.jsondefinitions are enough to reconstruct the site without us - Free hosting via watchers: GitHub Pages, Cloudflare Pages, Vercel, Netlify all auto-deploy when they see a push
- Diff-friendly audit log: each deploy's commit shows exactly what changed at the byte level, in Git's native UI
How to use
See deploy-targets.md → github for env vars + token rotation.
Not in scope
- Bidirectional sync — we push only; customers can't edit the repo and have it flow back to StaticOwl
- Atomic blue/green at the GitHub-side hosting layer (that's
manifest-pointer) - Lambda hosts the publisher — Lambda has no
gitbinary; the publisher must run on EC2
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:
- The content node at its bundled version
- The template at its bundled version
- The route map at its bundled version
- The theme at its bundled version
- The site settings at their bundled version
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:
- Finds the active Deployment for
(envId, atTime)— the one withstatus='deployed' AND validFrom <= atTime, latest first - Reads each
INCLUDESedge →(versionableId, version, at) - For each Versionable, queries the engine at the bundled
attimestamp using bitemporalMATCH (n) AT '<iso>' - 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
- SOX — provable record of who shipped what, when, with what reviews
- HIPAA — provable record of what patient-facing content was live during a treatment window
- FDA Part 11 — provable record of regulated drug info shown to providers
- GDPR — provable record of what consent UI was shown when
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
- Replay across schema breakages — if a content type was deleted, replay still works for nodes of other types but the deleted type's nodes won't render. (Mitigation: don't delete types; archive them.)
- Replay of media assets that have been actively purged from the asset bucket. (Engine-level retention covers this; see operations.md.)
Reviews (deep dive)
Reviews gate deploys.
Kinds
ai— Claude / GPT runs an audit (e.g., voice-profile compliance, factual fluency). Findings carry severity. Default for AI-authored Releases.automated— deterministic checks (broken links, missing alt text, schema validity, dependency-hash freshness). Run onpendingtransition.manual— human approval. Reviewer flags findings or approves.compliance— regulated checks (PII detection, accessibility, custom rules). Optional, per Enterprise plan.
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
- Architecture — the data model these features ride on
- API reference — every endpoint
- Deploy targets — where artifacts go
- Lifecycle hooks — code that runs at save / render / publish
- AI capabilities — voice profile, document import, visual edit, bulk rewrite