Versions & Snapshots
Date-based immutable brand-package version snapshots, pnpm brand:snapshot, and the Portal runtime API endpoints.
Versions & Snapshots
Brand packages are versioned and immutable so the Portal can pin a version, diff two versions, and explain what changed. Versions are date-based — clearer for brand governance than semver, and matching how brand locks are already referenced (e.g. "v2 lock 2026-05-06") (ADR 0002 §7).
Snapshot layout
Each version is an immutable directory under the tenant's versions/:
registries/{ns}/
brand-system.css # working copy (next version in progress)
versions/
2026.06.05/ # immutable once published
brand-system.css
brand-profile.json # governed profile instance (git-reviewed)
requirements.schema.json
asset-manifest.json # asset IDs, versions, signed-URL policy, alt text, usage rules
guidelines.json # normalized docs summaryThe first Vandoko snapshot is registries/vandoko/versions/2026.06.05/.
brand-profile.json— the tenant's contract-instance values ({ namespace, contractVersion, tokens }), validated byBrandTokenProfileSchema.requirements.schema.json— generated from the canonical token contract.guidelines.json— a normalized, versioned summary of the brand guideline docs (sections, headings, asset refs) that the Portal's Guideline Viewer renders with its own components.asset-manifest.json— the per-tenant asset index plus the signed-URL policy.
Publishing — pnpm brand:snapshot
pnpm brand:snapshotbrand:snapshot (scripts/brand-snapshot.mjs) freezes the working copy into a new versions/{date}/ directory.
Immutability is enforced at publish time. brand:snapshot refuses to
overwrite an existing version directory, and git review backs it up — handlers
only ever read committed snapshot files, never the working copy. A published
version never changes.
Each brand-system version gets a changelog entry so Portal snapshots can reference it to explain what changed.
Portal runtime API
The Portal consumes brand packages at runtime via an authenticated API, distinct from the shadcn-CLI install path. The endpoints (lib/brand-system-api.ts, app/api/brand-system/**):
GET /api/brand-system/{brandId}/latest -> newest version payload
GET /api/brand-system/{brandId}/{version} -> immutable snapshot
GET /api/brand-system/{brandId}/{version}/{file} -> one snapshot file
POST /api/brand-system/validate -> validate a brand-profile against the contractThe {file} whitelist is brand-system.css, brand-profile.json, requirements.schema.json, asset-manifest.json, guidelines.json. brandId is the serving-namespace slug; versions are date-based (2026.06.05).
Gating
This route is key-gated like /r/*, not Clerk-session protected — proxy.ts matches it before the generic /api/* auth.protect() fallthrough. Every branch is force-dynamic + no-store.
| Request | Credential | Status |
|---|---|---|
GET /api/brand-system/{brandId}/… | platform key | 200 (any brand) |
GET /api/brand-system/{brandId}/… | namespace key bound to {brandId} | 200 |
GET /api/brand-system/{brandId}/… | namespace key bound elsewhere | 403 JSON, no-store |
POST /api/brand-system/validate | any valid credential | 200 (tenant-neutral schema check) |
any /api/brand-system/* | none / invalid | 401 JSON, no-store |
| unknown brand / version / file | authorized | 404 JSON, no-store |
The full matrix lives in the Registry Auth & Cache Contract (docs/registry-auth-contract.md).
Portal docs consumption = the summary API. The Portal renders
guidelines.json with its own components rather than reading the Registry's
MDX directly — this keeps the client-facing Portal decoupled from the
Registry's Fumadocs internals and lets the Portal pin a version.