---
id: fvcms.pact.constitution.001
freshvibe_way_version: v8
date: 2026-07-09
last_rewritten: 2026-07-31 (Phase 8 v2: content admin in chrome, content-types from per-site store, widgets from gallery)
---

# FreshVibe CMS — the rules of the road

This document is the **constitution** for the freshvibe-cms package. It says what the package is, what it isn't, the rules it follows, and the promises it makes to anyone using it.

If you're writing code that lives in this folder, read this first. If you want to ship a feature, the rules below tell you whether your feature fits.

---

## 1. What FreshVibe CMS is

FreshVibe CMS is a **frontend editing runtime + page builder + full CMS admin + AI agent**. You load it on an HTML page and you get the ability to click on any region of the page and edit its content (text, images, buttons, headings, layouts) right there, with no rebuild step — plus the full content management surface (content types, records CRUD, templates, taxonomies, navigation) accessible from the operator's `{ }` Dev button.

**§1 amendment (2026-07-31, per fvcms-website-mavis b225 + operator signoff):** freshvibe-cms is the **frontend editing runtime** AND the **page builder** AND the **full CMS admin** AND the **AI agent**. These are not separate products. The CMS admin is part of the runtime, lives in the editing chrome, and is operator-driven (per §7 below). The content types and records are stored per-site (no central VPS service). The widget/tool code is consumed from the gallery (per the FVW v8.3 §17.5 doctrine, see §3.15 below).

Concretely, it provides:
- Yellow clickable tags floating on each region and module
- A dock panel with three tabs: Overview / Structure / Inspector
- Click any region → its inspector opens
- Click any module → its editor opens with the right fields for that type
- Save your edit → the DOM patches in place, no reload
- `{ }` Dev button → opens the editor chrome with the **content admin** panel (records CRUD per type)
- `{ }` Dev button → opens the **save-for-reuse** panel (review content types VibeCoder invents)
- `{ }` Dev button → opens the **templates** panel (manage starting points for new records)
- AI agent → wired through the chrome per §3.11-§3.14

**It is not WordPress.** There's no `/wp-admin`, no PHP, no separate admin user roles. The content admin is **operator-driven, runtime-resident, lives in the editing chrome** (per §7).

**It is not a page builder** in the standalone sense. It borrows patterns from Elementor / Bricks / Divi (section/widget outlines, click-to-edit, inspector with fields), but it's not a clone. The intent is a frontend CMS that looks a bit like Elementor in places.

**It is a runtime** that any HTML page can adopt. The host site declares its regions and modules via an `annotation.json`; FreshVibe CMS provides the editor overlay AND the content admin.

---

## 2. What's in the package

The package is organised by **feature**. Each feature is one user-facing capability (e.g. "the outline system" or "the inspector panel"). Features are grouped into bigger areas:

| Area | What it does | Where it lives |
|---|---|---|
| **Editor features** | The click-to-edit experience — outline tags, inspector panel, structure tree, breadcrumb, right-click menu, selection state | `app-fragments/editor-*/` |
| **Content admin** | The content admin for records CRUD, content types, templates. Lives in the editing chrome. | `app-fragments/editor-content-admin/`, `app-fragments/editor-save-for-reuse/`, `app-fragments/editor-templates/` |
| **Content types engine** | The content-type schema, intent-to-type mapping, per-site store, cache, descriptors. The 7 canonical defaults ship in the package. | `runtime/content-types/`, `runtime/content-store-bootstrap.js`, `schemas/content-types-default.json` |
| **Records engine** | The per-site records store, per-type forms, CRUD. | `runtime/content-items/`, `runtime/content-items-bootstrap.js` |
| **Runtime engine** | The plumbing — store, scope, renderers, region rendering | `runtime/` |
| **Layout modules** | The 15+ canonical building blocks (heading, paragraph, image, button, …) consumed from the gallery | `avidtech6/fv-module-gallery/cms-widgets/` (referenced, not vendored) |
| **Framework adapters** | One per page-builder ecosystem (only Elementor today) | `detectors/` |

The **editor features** area is the main thing we're building right now. The **content admin** area is Phase 8 v2 (shipped 2026-07-31). The **content types / records** engines are the runtime behind the content admin. The **layout modules** area is consumed from the gallery (per §3.15), not vendored.

The **runtime engine** and **layout modules** areas already work and are battle-tested with Oscar's Tree Academy.

---

## 3. The rules

These are non-negotiable. They exist so the package stays maintainable as features get added.

**The 3 non-negotiable principles** (a499 audit Finding 8, locked in v14):

1. **User autonomy** — the AI is a faster path to what the operator can already
   do manually via the chrome. If a manual method doesn't exist, the AI method
   doesn't ship. No capability is locked behind automation. (Cross-references
   the philosophy doc, which is the canonical statement.)
2. **Portable recipe books** — every module ships with a recipe book so any
   consumer who can read the recipe can re-implement the module independently.
   (Cross-references the philosophy doc.)
3. **Store as source of truth** — the engine, UI, and AI all read from the
   same store. No shadow state in any component. (Per §3.14 below.)

These three are the package's promise to its consumers. They cannot be removed
or weakened by a 1/2 or 2/3 vote; they require a major version bump and a
fork.

### 3.1 The engine must work in any HTML site

The runtime has **two layers** that mirror the split between generic code and framework-specific glue:

**Layer A — generic engine.** Everything that's not specific to a particular page-builder framework. This includes the store, the scope system, the region renderer, the config-from-DOM adapters, the panel manager wrapper, the layout modules, the editor features.

This layer MUST NOT mention `Elementor`, `WordPress`, `Gutenberg`, `Bricks`, `Webflow`, `Beaver`, `WPBakery`, or `Divi` in selector or classList logic. Comments that document *why* a canonical module exists (e.g. "M-button aliases EAEL creative buttons") are fine. But no runtime behaviour can depend on a specific page builder's CSS classes.

**Why:** the runtime should work in any HTML site — Elementor today, Shopify Liquid tomorrow, plain HTML next week. If Layer A leaks framework names into selectors, every consumer inherits dead code.

**Layer B — framework-aware renderers** (in `runtime/renderer.js`). Two renderers today (`M-button`, `M-testimonial`) probe for Elementor/EAEL class names as fallback selectors after trying generic ones. Plain HTML still works — the framework selectors are just there to help Elementor pages find the right DOM element.

**The migration plan:** when a second consumer (Shopify, Webflow, Bricks) needs different fallback selectors, Layer B moves to a plugin folder (`runtime/renderer-adapters/`). Each consumer ships its own adapter. Until then, the hybrid approach is simpler. (Tracked as `stubs/renderer-adapters.md`.)

### 3.2 Renderers patch in place, never replace

The Stage F renderer MUST patch DOM in place. It MUST NOT wipe a host element with `innerHTML = ''` to re-render from scratch.

**Why:** host framework CSS classes (EAEL's creative-button styling, Elementor's heading typography, Gutenberg's block CSS) must survive the edit. The visual identity of the page is preserved because we never throw it away.

### 3.3 Layout types are canonical, instances are user-renamed

`moduleDef.id` is a fixed, canonical name: `heading`, `paragraph`, `image`, `button`, `carousel`, `cta`, `testimonial`, `cta-box`, `accordion`, `icon-list`, `info-box`, `menu`, `social-icons`, `video`, `contact-form`, `breadcrumb`.

The user-facing instance name (what shows in the inspector and the structure tree) lives in `moduleInstance.displayLabel` and `moduleInstance.legacyConfig.__instanceName`. Renaming an instance MUST NOT change its canonical type. The renderer for `M-button` is the same regardless of whether you called the instance "Hero CTA" or "Buy now".

### 3.4 The runtime doesn't touch host DOM

The runtime provides overlays (region outlines, panel manager UI, inspector). It MUST NOT scrape or rewrite host DOM elements.

**Why:** if the runtime starts coupling to host DOM, it stops being reusable. Every consumer would need its own fork.

### 3.5 Save is idempotent

Every edit produces a `putModule` / `putRegion` call. Replaying the same edit produces the same DOM. This is what makes undo work — undo is just "replay the last N edits in reverse."

### 3.6 One panel system, not five

All overlays (region outlines, CMS panel, inspector panels, future breadcrumb) use the same PanelManager singleton. No parallel CSS overlay systems. The panel manager already handles drag, dock, slim-pill, multi-panel stacking, snap-to-edge. Don't reinvent it.

---

### 3.7 Panels are FES-managed or website-managed, never both

A panel has exactly one owner. Either the FES owns it, or the website owns it. The two never share an implementation.

- **FES-owned panels** ship with the package: Module widget (per §3.10), Navigator (per `app-fragments/editor-navigator/fragment.md`), Outline, Context menu, Breadcrumb, Renderer, Store, Panel Manager, canonical M-* modules.
- **Website-owned panels** ship with the website: Dev panel, CMS panel, Region panel, anything with site-specific UI.

A panel is created by exactly one place. A change to a panel's body is the responsibility of that panel's owner. The FES does not patch into a website's panel. The website does not patch into an FES-owned panel. A website that wants to override an FES-owned panel does so by NOT installing the FES panel and providing its own.

### 3.8 Chip-click opens or focuses the panel for that thing. Tabs inside a panel are a separate, host-app concern.

Click a region chip on the page → a region panel for that region opens in the dock (or focuses if already open). Click a module chip → a module panel for that module opens. Multiple module panels can be open simultaneously (one per selected module).

Selection is the only routing signal for which panel to open. The FES does not swap content inside an existing panel when a chip is clicked.

**Tabs inside any panel are an independent, host-app affordance.** The Dev panel may have tabs, the CMS panel has tabs, the Region panel may have tabs, the Module panel has tabs (Fields / Variants / Raw JSON per the FES editor-shell design). The pact does not define tab content. The pact only defines: chip click opens a separate panel, never swaps content inside an existing one.

### 3.9 Each area is a module. Hooks between modules are named and minimal.

Per FreshVibe Way V8 §10 ("Modules as Clusters"), the CMS is split into modules — clusters of features. Each module lives in its own folder under `app-fragments/`. Modules **only** talk to each other through **named, minimal hooks** declared in the module's `fragment.md`.

**The public hook surface for each module is its `index.js`**. The private implementation lives in `runtime.js`, css files, and test files. No module imports from another module's `runtime.js`. Cross-module imports go through the other module's `index.js`. Implementation details stay private.

If you want to swap the Navigator for a different one, you only change the Navigator folder. The other modules don't know the Navigator exists — they only know "there is a thing that gives me a tree, and clicking a row gives me a selection." That's the entire Navigator contract.

**If swapping one module for a different one requires changes in another module, the boundaries are wrong.** This is the modularity test.

### 3.10 A "widget" is the control surface of a module.

In Elementor, a widget is the editing panel that appears when you click a module on the page. In this CMS, the same word: the **widget** for `M-heading` is the form with text/level/color/align/font fields. The widget is the module's chips + behaviours rendered as the panel body.

- The **module** owns the data (canonical config) and the renderer.
- The **widget** owns the editing UI (chips + behaviours that show up in the panel).
- When you change a field in the widget, the module's renderer patches the live DOM in place (per §3.2).

**Tag colors** (operator-locked 2026-07-11):
- **Yellow** (`#ffdc64`) = CMS-level pills in the dock (Dev, CMS, any tool not tied to a thing on the page).
- **Brown** (`#a06a3a`) = Region tags on the page. Region tags float at the top-left of each region.
- **Purple** (`#a878e8`) = Widget sub-tags inside a region. Visible only when the region is selected.

Tags are visually independent. Tags of one type NEVER hide tags of another type. Every tag stays clickable regardless of what other tags are nearby. (No z-index war — each tag is in its own visual layer and clicks pass through correctly.)

**Drill-down navigation** (operator-locked 2026-07-11): in dev mode, top-level region tags are always visible. Click a region tag → its area on the page gets a brown highlight outline AND the widget sub-tags inside that region become visible. Other regions' widget sub-tags stay hidden. Click a widget sub-tag → open the widget panel for that widget. The operator navigates down a page by region, drills down to widgets.

### 3.11 Manual + AI both, always

The operator can do any task manually via the chrome. The AI can do the same task via the chrome. Neither mode is privileged. When the AI makes a change, the operator sees the change in the chrome exactly as if they had made it manually.

### 3.12 AI scope is explicit

The AI's authority is declared up front, not inferred. Each AI action declares:
- **scope**: which regions / modules / records / types it will touch
- **kind**: read / write / create / delete
- **count**: how many writes it intends

The operator can see the scope before the AI acts. The AI never acts outside its declared scope without asking first.

### 3.13 AI approval is 3-tier (per fvcms-website-mavis b238)

| Tier | Trigger | Behaviour |
|---|---|---|
| **SMALL** | 1 write direct | AI makes the change, then asks the operator "save this?" |
| **MEDIUM** | 2-10 writes of one type | AI shows a summary of what it will write, asks for 1-click approval |
| **LARGE** | >10 writes or multi-type | AI shows a full diff, requires explicit per-record approval |

The thresholds are hard. SMALL doesn't escalate silently to MEDIUM. MEDIUM doesn't escalate silently to LARGE. The operator always knows which tier they're in.

### 3.14 The store is the source of truth

Whatever the AI writes, the store holds. Whatever the operator writes manually, the store holds. The chrome is a view onto the store, not a parallel source of truth. Edits from the AI and edits from the operator go through the same write path.

This is why per-site stores matter: the AI and the operator are both writing to the same per-site PocketBase (or IDB fallback), so they can't diverge.

### 3.15 Widgets and tools come from the gallery (per FVW v8.3 §17.5)

The widget code and the tool code are NOT vendored into freshvibe-cms. They are consumed from `avidtech6/fv-module-gallery/cms-widgets/` at build time. The freshvibe-cms chrome references widgets by their gallery IDs (M-heading, M-button, M-image, etc.).

**Why:** the gallery is the canonical home for widget code (per FVW v8.3 §17.5.2, `_module_kind: gallery-original`). freshvibe-cms is a consumer, not a vendor. Updating a widget in the gallery propagates automatically to all freshvibe-cms builds on next build.

**Symmetry with content-types:** widget CODE lives in the gallery (canonical). Content-type DATA lives in freshvibe-cms (per-site, canonical). The two are different kinds of things, but both follow the rule "one canonical home, consumers don't vendor."

### 3.16 DNA format is mandatory (a499 Finding 1, fixed in v14)

Every app that vendors freshvibe-cms MUST ship a `dna/app.dna.json` file
at the package root. The DNA file is the single source of truth for the
app's identity, tier, lineage, and capabilities. Format spec:

```ts
type AppDNA = {
  identity: { id: string; name: string; version: string };
  tier: "stable" | "experimental" | "legacy";
  purpose: string;
  source: string;                      // e.g. "avidtech6/myapp on GitHub"
  lineage: { parent: string; ancestors: string[] };
  capabilities: string[];
  integrations: string[];
  consumers: string[];
};
```

The vendored fvw-v8 doc `03-dna-format.md` is the canonical spec. The
in-repo `dna/app.dna.json` is the canonical example. Anti-drift checks
(I1 + I2 in `invariants.md`) validate this on every page load.

### 3.17 App-pact binding is the bridge (a499 Finding 2, fixed in v14)

An "fvCMS app" is any HTML site that vendors `freshvibe-cms` and declares
itself in `annotation.json` + `dna/app.dna.json`. The binding is:

- The host site loads `bootstrap.js` (or the vendored copy from the
  `app-fragments/` modules) and instantiates the editor chrome.
- The host site declares its regions and modules via `annotation.json`.
- The host site's per-site store (PocketBase or IDB fallback) holds
  the records; freshvibe-cms reads/writes through the store adapter.
- The host site's tier (from DNA) determines which panels/features
  are available.

The binding is one-way: freshvibe-cms depends on the app's DNA, not
the other way around. Removing freshvibe-cms from an app leaves the
app's content intact in the per-site store.

### 3.18 Engine-agnostic truth (a499 Finding 4, fixed in v14)

The runtime MUST work in three execution environments:

1. **Browser** — the primary environment, where the chrome + editor
   features run. All UI code lives here.
2. **Server** (Node.js) — for build-time operations, the fes_bridge
   microservice (port 3005), and any CLI tools.
3. **Edge** (Cloudflare Workers, Deno Deploy) — for low-latency
   proxy/cache/redirect operations.

The split: the **engine** (Layer A in §3.1) is engine-agnostic. The
**renderers** (Layer B in §3.1) and **chrome** (Layer C) are
browser-specific. No code in Layer A may import a browser-only
module without a polyfill or shim. Anti-drift check I3 enforces this
by failing the build if a browser-only import is detected in
`runtime/`.

### 3.19 Local CMS Module (operator-locked 2026-08-14)

Apps that vendor freshvibe-cms MAY include a `/cms-local/` directory
at the chrome loader root. The directory is read-only from the
CMS side — the vendored CMS never writes to it. The loader reads the
6 canonical files at boot, validates them against the schemas in
`schemas/cms-local.*.schema.json`, and deep-merges them with the
vendored defaults.

**6 canonical files** (all optional, all with `schema` field as
the version handshake):

1. `cms-theme.json` — theme tokens, dark/light overrides, fonts
2. `cms-panel-layout.json` — default panel size, snap threshold, max panels
3. `cms-permissions.json` — role definitions + per-(role, action, target) rules
4. `cms-context-rules.json` — predicates + show/hide/pin actions
5. `cms-ai-config.json` — LLM model, system prompt, tool allowlist, rate limit, fallback
6. `cms-dock-config.json` — per-edge (left/right/top/bottom) widths, mobile override

**§3.19.1 — Merge strategy per concern** (lowest → highest priority):

| Concern | Strategy |
|---|---|
| JSON tokens (theme.dark, theme.light) | deep-merge |
| JSON modules (theme.tokens, theme.fonts) | deep-merge |
| Arrays (panelLayout.order, permissions.rules) | replace (not concat) |
| Dock config (per edge: left/right/top/bottom) | per-edge deep-merge |
| URL query params (`?fvcms.theme.dark=1` etc.) | priority 4 (highest, per operator answer #4) |

**§3.19.2 — Fallback rules** (fail-safe boot):

- File missing (404) → silent. Use vendored default.
- Network error → log warning, use vendored default. Page must not block.
- Parse error → log warning, use vendored default.
- Schema version newer than loader knows → forward compat, skip, warn.
- Schema version older than loader knows → use, warn (you should upgrade).
- Schema ID missing → file is not a Local CMS Module file, skip.

**§3.19.3 — Versioning rules**:

- Each file schema field carries its JSON-Schema ID (e.g. `fvcms.cms-local.theme.001`).
- Bump the ID (e.g. → `.002`) when the schema changes incompatibly.
- The loader is a CONTRACT consumer: backward compat required for 1 minor version.
- Forward compat (loader reading a newer file) is best-effort: the loader logs a warning and skips unknown fields.

**§3.19.4 — Loader contract**:

- Loader source: `runtime/cms-local-loader.js` (~120 LOC, vendored in this package).
- Exposed at `window.__fvcmsLocalConfig` (the merged result).
- Dispatches a `CustomEvent(fvcms-local-ready, { detail })` on `document` when done.
- Idempotent: calling `loadCmsLocalConfig()` again re-fetches and re-merges.
- The chrome MUST NOT block on the loader. If the loader is slow, the chrome mounts with vendored defaults; the merged config is applied via a `fvcms-local-ready` event listener.

**§3.19.5 — Diff tool**:

- `scripts/cms-local-diff.mjs` — diff an app /cms-local/ against the vendored defaults.
- Exit code 0 = OK, 1 = errors.
- Used in CI: a deploy is blocked if the diff script reports parse or schema errors.

**§3.19.6 — Backward compatibility**:

- Apps without a `/cms-local/` directory continue to work (no changes).
- Apps with an empty `/cms-local/` (all 6 files with just `schema` field) continue to work (no changes).
- Apps with a partial `/cms-local/` (some files, not all) work — missing files use vendored default.
- 1 minor version compat window: the v1.2.0 loader can read v1.2.x Local CMS Module files. The v1.3.0 loader may add new schemas and break old files; old apps need to upgrade.

**§3.19.7 — Migration path** (for apps adopting the Local CMS Module):

1. Add the `/cms-local/` directory with all 6 empty files (just `schema` field each).
2. Run `node node_modules/@freshvibe/cms/scripts/cms-local-diff.mjs ./cms-local/` — expect OK.
3. Override one file at a time (e.g. start with `cms-dock-config.json` to fix the mobile dock).
4. Re-test the chrome after each override.

**History**: §3.19 added 2026-08-14 per the Local CMS Module architecture (operator-locked 7 answers: /cms-local/ folder, fvcms.cms-local.*.001 schema prefix, AI config kept, URL params kept, zod validation, 1 minor compat, one-file loader). Source: bulletins b1215+b1216 (B1 corrected deploy with Local CMS Module sample) + integration plan §6.1.

## 4. What not to do

---

## 6. Anti-drift rules

These rules prevent the package from drifting away from what it claims to be. Every commit should obey them.

1. **Verify before you refactor.** For every change, prove the current state in a browser (curl, Playwright, manual check) BEFORE editing.
2. **Safe Diff Protocol.** Atomic commits. Commit messages say what diverged. Shadow versions of old code when you replace something risky.
3. **No invention.** Only refactor what exists. New features get their own commit with operator sign-off.
4. **Explicit binary verification.** Every change has a test — Playwright, vitest, curl-based, whatever proves the change works.
5. **DOM hierarchy verification.** The structure is preserved — heading stays a heading, section stays a section.
6. **Behaviour/state verification.** Same inputs produce same outputs. Edit → save → reload → DOM matches.
7. **Drift detection.** Every commit checks `git diff` and confirms the intent matches the diff.

---

## 7. What freshvibe-cms is NOT

To remove all doubt:
- **Not WordPress.** No `/wp-admin`, no PHP, no WordPress-style admin user roles. The content admin lives in the editing chrome, not a separate admin URL.
- **Not a separate admin panel.** **§7 amendment (2026-07-31, per operator signoff on Phase 8 v2):** the content admin is **operator-driven, runtime-resident, lives in the editing chrome**. There is no standalone admin UI, no `/admin` route, no separate admin service. The operator opens the chrome with `{ }` and the content admin is one of the panels in the dock.
- **Not a central content store.** Per-site content is stored in a per-site PocketBase (or IDB fallback). There is no shared VPS service that all freshvibe-cms consumers depend on for their content. Each site is its own tenant.
- **Not a vendor of widget code.** freshvibe-cms does not own widget implementations. Widget code lives in `avidtech6/fv-module-gallery/cms-widgets/`. freshvibe-cms consumes widgets by gallery ID (M-heading, M-button, M-image, etc.).
- **Not Elementor / Bricks / Divi.** We borrow patterns. We don't wholesale clone.
- **Not an SSR runtime.** The page is already rendered. FreshVibe CMS overlays it.
- **Not coupled to any framework.** Detectors per framework. Runtime agnostic.

---

## 8. Consumers

Apps that use freshvibe-cms today or might tomorrow:

| Consumer | Status | Notes |
|---|---|---|
| **Oscar-web** (`avidtech6/oscar-web`) | active | Elementor + EAEL detector glue. The host-specific stuff lives in Oscar-web, not here. |
| **FreshVibe Studio** | future | Stencil output. |
| **Plain HTML** | future | Any HTML page with an `annotation.json`. |
| **Shopify Liquid** | future | Liquid output. |
| **Webflow** | future | Webflow-detected sites. |

Each consumer can override any feature. If they only override a couple of files, they're "light users." If they mirror a chunk, they're "medium overrides." If they fork the whole thing, they're a "full fork." The thresholds (20% / 50%) come from the FreshVibe Way V8 doctrine.

---

## 9. How it boots

When the page loads, the editor comes up in this order:

```
HTML page loads
  → bootstrap.js runs
  → initCms() opens the IndexedDB store
  → loadAnnotation(annotation) seeds pages / regions / modules
  → ensureDefaultAdapters() registers DOM-config adapters
  → populateAll(store) reads live DOM and fills in legacyConfig for each module
  → connect() binds each module to its DOM element (.elementor-element-{id} etc.)
  → subscribeToStore() makes any putModule() trigger a re-render
  → showRegionOverlays() spawns one panel per region with a yellow tag
```

After that, the page is editable. Click → inspector opens → edit → save → DOM patches in place.

---

## 10. Where to look next

- `invariants.md` — the formal rule list (extends §3 above)
- `app-fragments/fragments.md` — every feature in the package, indexed
- `stubs/` — explicit placeholders for features that don't exist yet
- `README.md` — package entry point
- `docs/module-authoring.md` — how to write a new layout module
- `docs/ai-integration.md` — how AI integrates (Phase 4.3 forward)

## 11. The DAO actor (operator governance)

The DAO is the collective of operators who maintain this package. It is a
**convention**, not a protocol. No blockchain, no tokens. The mechanism is:
a proposal is posted as a thread on Mavis HQ with `ref_plan: aNNN`, the
comment count is the vote count, the implementation is a git commit when the
threshold is met.

Each operator has:

- A **role** (assistant / coder / editor / reviewer / tester — see
  `roles-config.json` in this directory)
- A **vote weight** (1.0 for active operators, 0.5 for observers)
- A **scope** (which parts of the codebase they can vote on)

The role definitions live in **`app-pact/roles-config.json`** (in this
repo, not a separate package). The 5 active roles, their vote weights,
their scopes, and their breaking-change/deprecation flags are all defined
there. Consumers read the file at boot to know what each role can do.

**DAO actions** (thresholds per the architecture doc):

| Action | Threshold | Notice |
|---|---|---|
| Feature proposal | 2/3 majority | 1 week |
| Breaking change | 2/3 majority | 1 week |
| Module deprecation | 2/3 majority | 4 weeks migration window |
| Security patch | 1/2 majority | immediate |

**Why this is in `app-pact/` and not a separate package**: the file is
governance data for THIS package, lives next to the other governance
documents (`app-pact.md`, `anti-drift.md`, `invariants.md`, `dna/`,
`rules/`, `shadow/`). Shipping it as a separate npm package would add
friction (clone, install, version pin) for no benefit (the file is
50 lines of JSON). If a future use case requires a separate package
(e.g. multiple fvCMS forks sharing a roles source), promote it then.

**History note**: this section was added 2026-08-05 18:30 BST after an
audit showed that the fvCMS architecture doc referenced "roles-config
in fv-vibechat" but no such package existed; the file was created
directly here instead. See the operator-locked decision in a493 v14
and the agent memory entry "The 'right call' rule".

## 12. Cross-references and resolved findings (a499 batch 2, v16)

This section closes the remaining 14 findings from the a499 audit
(10 HIGH + 5 MEDIUM + 5 LOW = 20 total). Sections 1-11 above closed
findings 1, 2, 3, 4, 8, 9, 10. This section closes 5, 6, 7, 11, 12,
13, 14, 15, 16, 17, 18, 19, 20.

### 12.1 Folder layout is 9, not 7 (a499 Finding 5)

The canonical freshvibe-cms layout has **9 top-level folders**:
`assets/`, `components/`, `controllers/`, `migrations/`, `templates/`,
`tests/`, `hooks/`, `locales/`, `config/`. This matches FWV v8.3
§2.1 (`vendored/fvw-v8/02-folder-layout.md`). The 7-folder variant
referenced in some legacy v6/v7 docs is superseded; new code MUST
follow the 9-folder layout. Anti-drift check I3 enforces this on
every page load (see `anti-drift.md`).

### 12.2 File extension rule (a499 Finding 6)

- `.tsx` for React components (anything that returns JSX)
- `.ts` for everything else (utilities, types, runtime logic, config)
- `.js` only for vendored files that can't be re-typed
- `.mjs` for ESM-only modules with explicit imports

The runtime MUST NOT mix `.ts` and `.tsx` for the same module. If a
file needs both JSX and pure-logic exports, split into two files
(`Foo.tsx` for the component, `foo-logic.ts` for the helpers).

### 12.3 Tier definitions (a499 Finding 7, REVISED v17)

**The 20%/50% is NOT a tier definition. It is the module override
threshold** (see §12.14 below and FvW v8.3 §10.3 + §18.5). I had
this wrong in v16; the operator caught it.

The freshvibe-cms tier model is **3-tier (stable / experimental /
legacy)**, defined in §3.16's DNA spec. This measures how
production-ready a single app's DNA declares itself to be. It is
INDEPENDENT of the FvW tier model (proprietary / off-the-shelf /
generic, per `17-modules-and-tiers.md`), which measures how a
module is licensed and distributed.

Apps MAY declare both. Example: an FvW-tier-generic widget
vendored into an fvCMS-stable app has `tier: "stable"` in DNA and
`license: "MIT"` in the FvW module.json. The two axes are
orthogonal — a stable fvCMS app can use generic-tier widgets.

### 12.4 Deployment workflow includes audit step (a499 Finding 11)

The 5-step deployment workflow from the recipe pattern (§3.17 cross-
reference + vendored `19-recipe-extraction.md`):

1. **Build** — `npm run build` produces the vendored bundle
2. **Test** — `npm test` runs the in-repo test suite
3. **Audit** — `fvcms audit` (or `npm run audit`) runs the audit
   checks (DNA, app-pact binding, drift discovery, engine-agnostic,
   user-autonomy-non-negotiable, 9-folders, .ts/.tsx, tier model).
   **Audit must pass before deploy.** No exceptions.
4. **Deploy** — push the bundle to the runtime target (CDN, edge,
   server). Records the deployment in `app-recipe/deployments/`.
5. **Verify** — hit the deployed URL with `curl -I`, confirm 200 +
   version header. If verify fails, the deployment is auto-rolled
   back per vendored `35-path-a-deploy-rollback.md`.

The audit step is non-skippable. A deploy without an audit is treated
as a violation of the constitution (§3 of the deployment recipe).

### 12.5 Runtime Validator enforcement (a499 Finding 12)

The Runtime Validator is the in-browser code that runs the 5
invariants (I1-I5 from `anti-drift.md`) on every page load. It is
NOT a CI tool; it runs in the user's browser. Enforcement levels:

- **I1 (DNA exists)**: hard fail — `bootstrap.js` throws, chrome
  shows fallback error page
- **I2 (DNA version matches)**: hard fail — same as I1
- **I3 (9-folder layout)**: hard fail — throws, no chrome mount
- **I4 (codex events)**: soft warn — yellow badge, chrome mounts
  with degraded event tracking
- **I5 (recipe ingredients exist)**: soft warn — yellow badge, the
  affected ingredients show as "missing"

A page can run with soft warnings, but hard failures block the chrome
from mounting. The CI audit (`fvcms audit`) is the same logic, run
at build time, and acts as a gate (build fails if any hard check
would fail in the browser).

### 12.6 Tier interaction rules (a499 Finding 13)

When modules of different tiers interact (one imports another):

- `stable` can import from `stable`, `experimental`, `legacy`
- `experimental` can import from `experimental`, `legacy`
- `legacy` can only import from `legacy`
- `stable` MUST NOT be imported by `experimental` or `legacy`
  (downgrade is forbidden; refactor the lower-tier module instead)

The tier is read from each module's `module.json` `tier` field. The
runtime enforces this on import: a `stable` module that sees a
`legacy` import throws at import time. The audit catches violations
at build time.

### 12.7 Event handling mechanism (a499 Finding 14)

Events propagate through a single global event bus:

- **Producer**: any code that needs to announce a state change calls
  `bus.emit(eventName, payload)`. Events are typed; the type is
  declared in the module's `codex.md` (per §3 cross-reference to
  FvW v8.3 §22).
- **Consumer**: any code subscribes via `bus.on(eventName, handler)`.
  Subscriptions are lazy (no eager init).
- **Scope**: events are scoped to the page lifetime. There is NO
  cross-tab event bus; use the per-site store for cross-tab state.
- **Order**: events fire in registration order. There is no
  priority or preemption.
- **Standard events** (declared in `codex.md`): `region:mounted`,
  `region:unmounted`, `widget:selected`, `widget:edited`,
  `content:saved`, `drift:detected`, `panel:opened`, `panel:closed`,
  `ai:proposed`, `ai:approved`, `ai:rejected`. Vendored modules
  MAY add their own events but the standard 11 are reserved.
- **Performance**: handlers are sync; async work happens in the
  handler. There is no debounce built in; if a hot event needs
  debouncing, the consumer does it.

The 5 invariants (anti-drift.md I1-I5) check that emitted events
match the codex declarations; this is the runtime audit for
event-contract drift.

### 12.8 Anti-drift worked example: the button widget (a499 Finding 15)

Concrete anti-drift checks for `M-button` (the button widget):

- **Codex contract** (in `cms-widgets/button/codex.md`): emits
  `widget:selected`, `widget:edited`; consumes `region:mounted`,
  `content:saved`. No other events.
- **Implementation check** (in `cms-widgets/button/`): grep the
  source for `bus.emit(`. Each match MUST be in the codex list.
  A new `bus.emit("foo:bar")` not in the codex is a HIGH drift.
- **Recipe ingredients** (in `cms-widgets/button/recipe.md`):
  the recipe lists `button.tsx`, `button.css`, `button.codex.md`,
  `button.recipe.md`, `button.test.tsx`. All 5 files MUST exist.
  A missing `button.test.tsx` is a MEDIUM drift.
- **Render purity** (G-11): `button.tsx` MUST NOT have side effects
  on import. A `console.log` at module top level is a LOW drift.
  A `localStorage.setItem` at module top level is a HIGH drift.
- **Save idempotence** (§3.5): calling the save handler twice with
  the same payload MUST produce the same store state. A test
  asserts this in `button.test.tsx`.

The runtime runs I4 (codex events match) on every page load; the CI
audit runs the other 4 checks at build time. Worked examples for
other widgets (heading, image, paragraph) live in their own
`codex.md` files in the gallery.

### 12.9 Immutable infrastructure vs adaptability (a499 Finding 16)

The philosophy doc says fvCMS is **adaptable** (can be re-shaped per
site). The recipe pattern says fvCMS is built on **immutable
infrastructure** (per-site stores are append-only, no destructive
migrations). These are not in conflict: the **infrastructure** is
immutable (you can append, not rewrite), but the **adaptation** is
on top of that infrastructure (you write new content into the
append-only store). A site adapts by adding new records, not by
mutating the schema. Schema changes go through the DAO vote
(constitution §11).

### 12.10 Lifecycle management (a499 Finding 17)

Every fvCMS component has a 4-stage lifecycle:

- **mount**: `mount(container, props)` — DOM created, subscriptions
  opened, initial state loaded from store
- **update**: `update(props)` — called when props change; MUST be
  idempotent
- **unmount**: `unmount()` — DOM removed, subscriptions closed,
  refs released
- **destroy**: `destroy()` — for long-lived components only;
  releases any heavy resources (workers, connections)

Hooks fire at each transition:

- `beforeMount`, `afterMount`
- `beforeUpdate`, `afterUpdate`
- `beforeUnmount`, `afterUnmount`
- `beforeDestroy`, `afterDestroy`

Panels registered with the panel manager (per §3.6) get this
lifecycle automatically. Vendored widgets that bypass the panel
manager MUST implement the 4-stage lifecycle themselves; the
audit catches missing implementations at build time.

### 12.11 Configuration schema validation (a499 Finding 18)

`annotation.json` (the per-site config) is validated against
`schemas/annotation.schema.json` (JSON Schema 2020-12) on every
load. Validation errors block the chrome from mounting. The
schema covers:

- `regions[]`: required, ≥1 entry
- `regions[].id`: required, string, unique
- `regions[].selector`: required, string, valid CSS selector
- `regions[].modules[]`: required, each entry is a widget id from
  the gallery
- `dna`: required, must match `dna/app.dna.json` shape (per §3.16)
- `chrome`: optional, defaults are applied if missing
- `version`: required, semver

Unknown fields are warned, not blocked. Missing required fields
block the chrome. The validation is the same logic at build time
(`fvcms audit`) and at runtime (the browser validator).

### 12.12 Multi-file drift repair (a499 Finding 19)

When 3+ files drift at once (e.g. a content-type rename that
touches the schema, the admin UI, the runtime loader, and 5 sample
records), the single-file repair procedure doesn't scale. The
multi-file repair is dependency-aware:

1. **Build the dependency graph** between the drifted files
   (imports, references, store mutations).
2. **Topological sort** the drifted files so the leaves are
   repaired first.
3. **Repair in order**: each file's repair is committed + pushed
   + verified before the next file's repair. Each commit
   references the previous by SHA.
4. **Verify between commits**: after each commit, run the full
   test suite + audit. A failure rolls back the commit and
   pauses the multi-file repair.

A multi-file repair that touches >5 files is escalated to the
DAO (constitution §11) for approval, because the regression risk
is high enough that an operator should review the plan.

### 12.13 Code-structure drift detection (a499 Finding 20)

Beyond file-existence drift (the bash checks in I1-I5), the audit
also diffs code structure against the canonical recipe:

- **AST diff**: the source is parsed (TypeScript via `tsc --noEmit`,
  CSS via PostCSS, JSON via `json.parse`) and compared to the
  recipe's expected AST. A function removed, a parameter added, a
  CSS rule deleted — all caught.
- **Import diff**: every `import` statement is compared to the
  recipe's import list. A new import not in the recipe is a
  MEDIUM drift. A removed import that the recipe requires is a
  HIGH drift.
- **Export diff**: every `export` is compared to the recipe's
  export list. Same severity as imports.

The code-structure diff is the slowest audit step (~30s for a
medium-sized vendored copy) so it runs in the weekly discovery
loop (per the 3-phase protocol in `anti-drift.md`) but not on
every page load.

**History note**: §12 added 2026-08-05 18:55 BST to close the
remaining 14 a499 findings that v15 missed. See a493 v16.

### 12.14 The 20% / 50% override threshold (operator-corrected v17)

The 20% and 50% thresholds in FvW v8.3 are **override thresholds**,
NOT tier definitions. They measure the **% of a vendored module's
files that an app has overridden** (customised away from the
canonical). The thresholds drive two different jobs:

**The 20% threshold (reflection trigger)** — per FvW v8.3 §18.5.0:

When the override % crosses 20%, the build **warns** the operator
that the fork is getting heavy. The warning asks: "Consider (a)
mainlining — if the change should be in the canonical, (b) forking
— if the change is sample-app-specific and shouldn't be in the
canonical." The build still uses mirrors in the bundle; the
warning is a moment to pause.

**The 50% threshold (sync mechanism)** — per FvW v8.3 §10.3 and
§12.6:

When the override % crosses 50%, the build triggers a **wholesale
fork action**: the whole module is downloaded as a single unit and
synced whole (rather than file-by-file). The mirrors are removed
from the bundle (the canonical provides the same code) but stay on
disk for audit. This is cheaper than file-by-file sync once more
than half the module is customised.

**Override % calculation** (per §18.5.0):

`override %` = proportion of forks in `fork` state (customised) vs
`mirror` state (still canonical). Files in `addon/handlers/` do
NOT count toward override % — handlers are for event-behaviour
overrides, not feature rewrites.

**Application to freshvibe-cms**:

When an app vendors `freshvibe-cms` and starts customising it:

- < 20% override: build proceeds silently. The 9-folder layout,
  the 8-artefact rule, the per-site store all stay canonical.
- 20-50% override: build emits a warning. The operator should
  consider: is this fork going back into freshvibe-cms as a
  contribution, or is it sample-app-specific?
- ≥ 50% override: build triggers wholesale fork. The app's
  `freshvibe-cms/` directory becomes a fully-owned copy. The
  mirror files (canonical copies) stay on disk for diff but are
  excluded from the build. The wholesale fork can still sync
  upstream by re-running `fvcms sync` (TODO: implement).

**The "tier 1 = >50%" and "tier 2 = 20-50%" framings in the a499
audit are misframed** — the operator caught this. The 20% and
50% are thresholds, not tiers. The audit's Finding 7 was
correctly identified as a contradiction; the resolution is that
the contradiction is in the AUDIT's framing, not in the docs.
The docs are consistent: §10.3 + §18.5 in the vendored FvW
specify the override rule precisely.

**History**: §12.14 added 2026-08-05 19:05 BST after operator
review of v16 §12.3. v16 had conflated the override threshold
with a tier model. v17 fixes the §12.3 text and adds this §12.14
as the canonical statement of the override rule in fvCMS terms.

### 12.15 Override threshold → gallery registration (operator-flagged v18)

The 20%/50% override threshold is a **build-time** mechanism (per
§12.14). It also has a **gallery-side** representation: how a
forked module is registered in the Fv Module Gallery. The two
sides must stay consistent.

**The `_module_kind` taxonomy** in `fv-module-gallery/registry.json`:

There are 3 kinds of module entries (per
`fv-module-gallery/gallery-pact/registry-plan.md`):

1. **`gallery-original`** — created in the gallery. No canonical
   source to mirror. Example: the `button` widget, which was
   migrated from `avidtech6/freshvibe-cms/modules/button.js` and
   is now a gallery-original. No override % applies (no upstream
   to diverge from).

2. **`gallery-mirror`** — mirrors an external canonical source.
   Example: the `ai-actions` module mirrors
   `avidtech6/oscar-platform/app/src/modules/ai-actions`. Override
   % applies (the mirror can diverge from canonical).

3. **`app-pointer`** — points to a sovereign app, not a vendored
   module. Example: `freshcards-standalone` is registered as an
   app-pointer to `avidtech6/freshcards-standalone`. Override %
   does not apply (it's a reference, not a vendored copy).

**The `state` field in `module-meta.json`** (per FvW §12, in the
gallery-plan):

For `gallery-mirror` modules, each variant has a `state` field:

- `state: "mirror"` — byte-identical to canonical. Build
  verifies byte-equality. Override % = 0.
- `state: "fork"` — customised. Diff is recorded. Override %
  contributes to the variant's weight.

**The connection to 20%/50%**:

When a `gallery-mirror` variant's override % crosses a threshold,
the variant must update its registration in `registry.json`:

- **At 0% (mirror)**: registered as `gallery-mirror` with
  `_mirror_status: "healthy"`. The build pulls from canonical.
- **At 0-20%**: still registered as `gallery-mirror` with
  `_mirror_status: "healthy"`. The build pulls from canonical;
  the variant is just a reVibe overlay on top.
- **At 20-50%**: registered as `gallery-mirror` with
  `state: "heavy-fork"` (valid per the
  `module-meta.schema.json` `state` enum: `mirror | fork |
  heavy-fork | deleted`). The build emits a reflection-trigger
  warning to the operator.
- **At ≥ 50%**: re-registered as a new entry with
  `_module_kind: "gallery-mirror"` but `_mirror_status: "forked"`
  (the wholesale fork has been triggered). The canonical is
  bundled alongside per §12.14.

If the operator decides the fork should be permanent (a real
divergence, not a temporary reVibe), the variant gets its own
top-level entry in `registry.json` with `_module_kind:
"gallery-original"` (it's no longer mirroring anything) and
`provenance.forked_from: <original-canonical-source>`.

**For freshvibe-cms specifically**:

The freshvibe-cms widgets live in `fv-module-gallery/cms-widgets/`
(per §3.15). They are all `gallery-original` (no canonical source
to mirror). Override % does NOT apply to them — they ARE the
canonical. If an app vendors freshvibe-cms and starts customising
a widget, that customisation is local to the app, not a mirror
divergence.

**History**: §12.15 added 2026-08-05 19:15 BST after operator
flag that "the 20%/50% touches on how modules are registered in
the gallery". Verified by reading
`/workspace/fv-module-gallery/registry.json` (24 gallery-mirror
+ 4 app-pointer entries, no gallery-fork yet) and
`/workspace/fv-module-gallery/gallery-pact/registry-plan.md`
(5-layer model, state field in module-meta.json). Locked v18.

### 12.16 Gallery registration requirements (operator-flagged v20)

Per the operator flag 18:55 BST "the mega plan needs to include
registration etc as per gallery pact" — the gallery-pact defines
3 schemas that every fvCMS widget must conform to:

1. **`cms-widget.schema.json`** — the rich widget metadata. Required
   fields: `id`, `kind` (enum: `widget` or `tool`), `name`,
   `version`, `provenance` (with `_module_kind` enum: `gallery-original`,
   `gallery-mirror`, `VIBECODER-GENERATED`, `operator-extension`),
   `evolution` (with `tier` enum: `experimental` / `stable` /
   `certified`), `dependencies`. Live at
   `fv-module-gallery/gallery-pact/cms-widget.schema.json`.

2. **`module-meta.schema.json`** — the FVW v8.2 module metadata
   (sibling schema). Required: `name`, `version`, `depends_on`,
   `_module_kind` (enum: `original`, `borrowed`, `gallery-original`,
   `gallery-mirror`, `app-fork`, `app-pointer`, `bundle`,
   `app-internal-module`, `app-internal-bundle`). For
   `gallery-mirror` and `app-fork`, `_mirror_status` is required
   (enum: `healthy`, `orphaned`, `reattached`, `promoted`).
   For `gallery-original`, `_canonical_source` MUST be null.
   For `app-fork`, `_forked_from` MUST be set. For variants
   inside the metadata, `state` enum: `mirror`, `fork`,
   `heavy-fork`, `deleted`. Live at
   `fv-module-gallery/gallery-pact/module-meta.schema.json`.

3. **`registry.schema.json`** — the top-level registry index.
   Each module entry requires: `id`, `path`, `version`, `tier`
   (enum: `proprietary`, `off-the-shelf`, `generic`), `tags`,
   `category`. Optional: `subcategory`, `default_facet`,
   `available_facets`, `deprecated`. Plus 9 auto-generated
   inverted indices: `tags_index`, `category_index`, `tier_index`,
   `consumer_index`, `dependency_graph`, `dependents_graph`,
   `facet_index`, `variant_index`, `stats`. Live at
   `fv-module-gallery/gallery-pact/registry.schema.json`.

**The connection to fvCMS**: per FvCMS constitution §3.15,
widgets are consumed from `avidtech6/fv-module-gallery/cms-widgets/`
at build time. For the consumption to work, the widgets must
exist in the gallery (Phase 5c.2) AND the widget's
`module-meta.json` must be valid per the schema (Phase 5c.4).

**The current state** (verified by reading the gallery on 2026-08-05
19:40 BST):
- 17 widgets have BOTH `module.json` and `module-meta.json` (all
  `gallery-original`, all `stable` tier, registered nowhere)
- 4 widgets have `module.json` but NOT `module-meta.json`
  (blog-post-card, blog-post-list, listing-grid, recipe-detail —
  all `experimental` tier)
- 0 of 21 widgets are in the gallery's `registry.json`
- 2 sub-namespaces (`theme-presets/`, `tools/`) need their own
  registration decisions

Phase 5c in a493 v20 captures the 6 sub-steps to fix this.

**History**: §12.16 added 2026-08-05 19:50 BST after operator
flag "you keep guessing in multiple areas, thainis a problem if
you don't know always find out." v20 was the v15-rebuild: I
actually read the 3 schemas + 2 workflow docs + STATUS.md
instead of paraphrasing. The 4 widgets missing `module-meta.json`,
the 21 widgets missing from `registry.json`, and the 9 auto-
generated inverted indices are all things I had been guessing
about. Locked the "read the source of truth, don't paraphrase
from memory" rule in a493 v20 lessons + agent memory.


