Architecture
The single design idea: content compiles into immutable artifacts, and deploys are pointer flips at the artifact layer. Everything else — A/B testing, drip publishing, replay, audit, multi-channel output — falls out of that.
This doc covers:
- The data model
- Request lifecycle
- Build pipeline
- The manifest-pointer deploy architecture
- Multi-tenant story
- Bitemporal layer
The data model
Six primitives in the graph. Everything else is a variation on them.
┌─────────┐ ┌────────────┐
│ Site │ ─[BELONGS_TO]──▶ │ Platform │
└────┬────┘ └────────────┘
│ owns
▼
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
│ ContentType │ │ Template │ │ Route │
│ (Versionable) │ │ (Versionable) │ │ (Versionable) │
└────────┬────────┘ └─────────────────┘ └─────────────────┘
│ HAS_TYPE
▼
┌─────────────────┐ ┌─────────────────┐
│ Content node │ │ Asset │
│ (Versionable) │ │ (Versionable) │
└─────────────────┘ └─────────────────┘
[Versionables get bundled into Releases]
│
▼
┌──────────────────────────────────────────────────┐
│ Release │
│ - immutable bundle of Versionable versions │
│ - state: draft | pending | approved | deployed │
│ … │
└──────────┬─────────────────────┬─────────────────┘
│ INCLUDES (each item) │ HAS_REVIEW
▼ ▼
┌──────────────────┐ ┌──────────────────┐
│ ReleaseInclude │ │ Review │
│ (snapshot data) │ │ kind: ai/auto/ │
│ │ │ manual/ │
│ │ │ compliance │
└──────────────────┘ └──────────────────┘
[Releases get pointed at Environments via Deployments]
│
▼
┌──────────────────────────────────────────────────┐
│ Deployment │
│ - immutable fact: releaseId + envId + validFrom │
│ - status: pending → building → deployed | │
│ failed | superseded │
└──────────────────────────────────────────────────┘
Versionable kinds
A Release bundles Versionables. Currently supported kinds:
| Kind | What | Source of truth |
|---|---|---|
content |
content nodes (articles, pages, products, …) | graph node, versioned |
template |
render templates (njk, handlebars, etc.) | graph node, versioned |
type |
content type definitions | graph node, versioned |
route |
URL → template/content mappings | graph node, versioned |
asset |
media (images, files) | content-addressed bytes |
settings |
site-level settings | graph node, versioned |
widget |
reusable layout fragments | graph node, versioned |
Output kinds
Templates declare an outputKind — what channel the rendered output is for. The build pipeline dispatches per kind. See features.md → Multi-output for the full registry.
| Kind | Default DPI | Print-bound | Status |
|---|---|---|---|
web |
72 | no | shipping |
social_post |
72 | no | foundation |
flyer |
300 | yes | foundation |
sign |
150 | yes | foundation |
qr_code |
300 | no | shipping (generator + tests) |
email |
72 | no | foundation |
ad_creative |
72 | no | foundation |
transcript |
72 | no | foundation |
"Foundation" means the registry + types ship; per-kind compilers land in subsequent phases.
Request lifecycle
Anatomy of a typical admin request (PATCH /api/content/:type/:id):
┌─────────────────────────────────────────────────────────────────┐
│ 1. CloudFront / API Gateway │
│ - TLS, WAF, edge caching of static UI bundles │
└────────────────────────────┬─────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────┐
│ 2. Express server (packages/server) │
│ Middleware chain on /api/*: │
│ a. cognitoAuthMiddleware → req.auth.{userId, email, …} │
│ b. platformContext → req.platformUser, .platformRole │
│ c. requireSiteContext → req.services.{graph, content, │
│ templates, scripts, schema, │
│ workflow, locale, transforms, │
│ site, role} │
└────────────────────────────┬─────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────┐
│ 3. Route handler (e.g., contentRoutes) │
│ - validates input │
│ - calls req.services.content.update(...) │
└────────────────────────────┬─────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────┐
│ 4. Service layer (packages/core/crud/*) │
│ - applies lifecycle hooks (onSave) via TransformService │
│ - writes to the graph via GraphClient │
└────────────────────────────┬─────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────┐
│ 5. Graphiquity engine (HTTP, /e/* on api.graphiquity.com) │
│ - bitemporal append-only writes │
│ - audit chain hash update │
│ - cypher response with the new state │
└─────────────────────────────────────────────────────────────────┘
For a public visitor request (the rendered site), the path is much shorter:
visitor → CloudFront edge → S3 (static-paths target)
↳ Lambda@Edge resolver → manifest → S3 artifact (manifest-pointer target)
↳ GitHub Pages / Cloudflare Pages / Vercel (github target, customer-hosted)
No app server, no database, no edge function in the visitor path when the deploy target is static-paths or github. That's the "no runtime to fail" pillar.
Build pipeline
packages/server/src/build/compile.ts is the entry point. The compileSite() function:
- Reads the deployment mode —
legacyreadsenvPublishedAt_<envId>scalars;newreads from the active Release;dualreads legacy and logs what new would do. - Resolves content — for the
newmodel, walks the active Release'sINCLUDESedges to find the bundled content nodes at their bundled versions (using bitemporalAT '<iso>'queries). - Renders each item — runs
beforeRenderlifecycle hooks, looks up the matching template, callsrenderPage(tpl, fields, opts). - Emits artifacts — HTML at
/blog/post-1/index.html, plus optional JSON sidecars at/_meta/pages/blog/post-1.jsonfor the GitHub target. - Dispatches to publishers:
static-paths: writes files to disk;deployToS3()syncs to S3.manifest-pointer: hashes each artifact, uploads tos3://artifacts/by-hash/<hash>.<ext>, builds a 256-shard manifest, atomically flips thecurrent/<envId>.jsonpointer.github: usesGitHubPublisherto clone the target branch, copy the build output, commit, and push.both: runsstatic-pathsandmanifest-pointerin parallel.
All publishers are gated by predicates in packages/core/src/observability/index.ts:
shouldWriteStaticPaths(target)shouldWriteManifest(target)shouldPublishToGitHub(target)shouldEmitSidecarMeta(target)(currently onlygithub)
The manifest-pointer deploy architecture
This is the "managed atomic-release" mode. It uses content-addressing + a sharded manifest + Lambda@Edge to deliver:
- Atomic blue/green via single-write pointer flip
- Instant rollback as a new pointer fact
- Content-addressed dedup — a 1M-page site changing one article writes ~5–10 files, not 1M
- Replay any past state at the CDN layer
- Provable audit chain end-to-end
Layout in S3
s3://staticowl-artifacts/
by-hash/<sha256>.<ext> ← every artifact, content-addressed
s3://staticowl-manifests/
<siteId>/<deploymentId>/
manifest-root.json ← totals, errors, hashAlgo
shards/00.json … ff.json ← 256 shards, xxhash32(path) → entry
<siteId>/current/
<envId>.json ← THE POINTER. Single S3 write = deploy.
Resolver flow (Lambda@Edge)
For a visitor request to https://example.com/blog/post-1/:
- L@E reads
s3://staticowl-manifests/<siteId>/current/<envId>.json→ gets{ deploymentId, validFrom, switchedAt }. - Hashes the request path with xxhash32 → picks a shard (
00–ff). - Reads
s3://staticowl-manifests/<siteId>/<deploymentId>/shards/<shardId>.json→ maps/blog/post-1/to{ hash, ext, size }. - Streams
s3://staticowl-artifacts/by-hash/<hash>.<ext>to the visitor.
The pointer file is the single source of truth for "what's live". Changing it is one S3 write. Reverting it is another. No mixed-state window — either the new manifest is in front of all visitors, or it isn't.
A/B variants on the manifest
A manifest entry can carry multiple variants instead of a single hash:
{
"/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 runs selectVariant() per request: sticky-by-cookie, deterministic across visits, independent across paths. See features.md → A/B testing.
Multi-tenant story
Tenancy lives at three layers:
- Engine — Graphiquity hosts N graphs. Each tenant has at least one graph (e.g.,
kindatechnical,acme-corp). Engine API keys are graph-scoped. - CMS server — One CMS server can serve many tenants by routing per
X-Site-Idheader. TherequireSiteContextmiddleware looks up the site'sgraphNameand constructs a per-requestGraphClientagainst that graph. - Per-tenant EC2 isolation (planned) — High-value tenants can be routed to dedicated EC2 instances via
graph_router.js. Path to ECS/EKS for full isolation.
Site = tenant boundary. Every content node, template, type, route, release, deployment carries a siteId and the engine enforces graph-scoped access. Cross-tenant reads/writes are not possible.
Bitemporal layer
The Graphiquity engine is bitemporal append-only. Every record has:
_valid_from/_valid_to— when the fact was true in reality_version— monotonic per-id_op—CREATE,UPDATE,DELETE
Writes never overwrite. A "delete" is a soft-delete (sets _valid_to). An "update" creates a new version (the prior version stays readable via AT '<iso>').
This is why Replay works: Site(env, time) reads each Versionable bundled in the Release that was active at time, with each Versionable resolved at its bundled version's timestamp. The result is byte-identical to what was live then.
This is also why Reviews + Deployments are append-only: a rollback is a new Deployment fact pointing at an older Release, not a mutation of an existing fact. "Who shipped what when and what was reviewed" is one Cypher query, not archaeology.
See ADR-0019 for the full deployment fact model rationale and features.md → Replay & Audit for the customer-facing capabilities this enables.
See also
- Deploy targets — env-var contract for static-paths / manifest-pointer / github / both
- HTTP API — every
/api/*endpoint - Lifecycle hooks —
onSave,beforeRender,onPublishexecution model - ADRs — the architectural decisions that got us here