RFC-0001: Site Graph contracts v1
- Status: Accepted (implemented in Sprint 1)
- Author: Halil, Date: 2026-10-07
- Related: ADR-0001, ADR-0002,
packages/core-contracts/schemas/
Summary
A site is a graph of typed, individually versioned nodes. Version 1 defines seven contracts — Project, Site, Page, Section, ContentEntry, Snapshot, SkillManifest — as JSON Schema with generated TypeScript and Rust types. This RFC records the model and its invariants so that later changes are measured against something written down.
Motivation
Sprint 1 needs a concrete model to generate from and to write fixtures against; later sprints (storage, renderer, MCP tools) build on it. Writing the model as contracts first keeps the “agent edits a typed graph” promise honest from day one.
Proposal
Nodes and references
Project ──1:n──▶ Site ──1:n──▶ Page ──embeds──▶ Section (hero | richText | image | contentList) │ ├──1:n──▶ ContentEntry (collection, slug, locale, body, fields) └──history──▶ Snapshot ──▶ Snapshot (parentId)- References are by prefixed id (
site_…,page_…,entry_…,snap_…), never by embedding, except Sections, which are embedded in their Page. - Page is the unit of locking, snapshotting and diffing; Sections have ids for stable diffs but no
rev. - ContentEntry separates typed core fields (title, slug, locale, status, body) from collection-specific
fields(free JSON, validated per collection later). - Snapshot lists
{id, kind, rev, hash}for every node of the site at that instant; bodies live in the blob store by hash. Publish and rollback only moveSite.publishedSnapshotId.
Invariants
- Every root document has
schemaVersion(currently1); readers reject other values; migrations move documents one version at a time. - Mutable nodes (Project, Site, Page, ContentEntry) carry
rev; writes are compare-and-swap onrev(ADR-0002). - Snapshots are immutable and form a linked list per site via
parentId. Site.pageIdsis the authoritative page order; a Page not listed there is unreachable but not deleted.- Paths are absolute, lower-case, no trailing slash (regex in
page.schema.json); one(siteId, locale, path)is unique (enforced by storage, not schema). - Actors (human, agent, system) share one shape; policy decisions never depend on the actor’s kind alone.
- Unknown properties are rejected everywhere except declared maps (
metadata,fields,theme.config).
Generator-safe subset
See ADR-0001. In short: oneOf + inline variants + single-value kind enum; no default; refs only to common.schema.json#/$defs/* or another root’s top level.
Alternatives considered
| Alternative | Pros | Cons | Why not |
|---|---|---|---|
Sections as separate nodes with their own rev |
Finer-grained locking | Diffs and snapshots explode; agents edit pages, not sections | Page-level locking matches the editing unit |
Single “Node” table with kind + JSON blob |
Simple storage | No typed contracts; Rust side becomes Value |
Defeats the contract goal |
| CRDT document per page | Concurrent editing | Complexity before the loop is proven | Deferred; optimistic locking first |
Compatibility & migration
First version; no migration. Future additive changes (optional fields, new Section kinds) are non-breaking. Removing or renaming requires schemaVersion: 2 with a migration function and both versions’ fixtures kept.
Security & policy impact
Ids are opaque handles that the MCP server re-authorizes per call (ADR-0004). metadata is string-only to prevent smuggling structured instructions into nodes.
Open questions
- Redirects, navigation menus and media assets as first-class nodes (likely v1 additive, Sprint 3).
- Per-collection schemas for
ContentEntry.fields(Sprint 4/5). - Whether
Sectiongains avisibility/schedulefield for the workflow engine (Phase 1.5).