Guidelines

Canon Tier Composition Guideline

**Version: v1** **Applies to: every canon-author file under `packages/components/src/{atoms,molecules,organisms,templates,pages}/**` or `packages/svelte/src/**`, plus consumer-tier canon-mirror files under `src/lib/components/canon/**`.**

v1

Canon Tier Composition Guideline

Version: v1 Applies to: every canon-author file under packages/components/src/{atoms,molecules,organisms,templates,pages}/** or packages/svelte/src/**, plus consumer-tier canon-mirror files under src/lib/components/canon/**.

This guideline is the operational rule body that the structural canon-tier-import-gate (warp#3826) enforces. It generalises the existing canon-tier-gate's narrow "page imports ≤1 template" rule (warp#3098) to the full five-tier Atomic Design hierarchy, closing the broader composition-discipline gap exposed by warp#3823 (canon primitives named structurally — "molecule" — but implemented bespokely, e.g. <span class="dot"> inside <gy-filter-chip> rather than composing the lower-tier <gy-dot> atom). PRs touching files in scope cite Guideline: canon-tier-composition@v1 in the PR body so persona reviewers (ADR-115) verify against this exact version.

Rule

A canon-author file may import only from STRICTLY-LOWER tiers.

The five tiers, ranked 1 (lowest) → 5 (highest):

Tier Rank May import from
atom 1 nothing canon — HTML primitives + tokens only
molecule 2 atom
organism 3 atom, molecule
template 4 atom, molecule, organism
page 5 atom, molecule, organism, template

Equal-rank (peer) imports refuse. Upward imports refuse. Atoms refuse any canon import at all — they sit at the base of the hierarchy and compose only from HTML primitives + design tokens.

@gyrum-labs/tokens is NOT a tier; it sits BELOW the atomic-design hierarchy. Token imports are always allowed, from any tier, including atoms. The gate's rank table (atom=1 … page=5) does not include tokens precisely because tokens are not a composition unit — they are leaves of the design system that every tier consumes.

The gate detects the violation via static import scan over .ts and .svelte files matching canon-author path shapes (.../atoms/..., .../molecules/..., .../organisms/..., .../templates/..., .../pages/..., +page.svelte, +page.ts). Import specifiers resolve to a tier via path shape (@gyrum-labs/components/atoms/dot/dot.js → atom) or, for opaque Svelte-adapter packages (@gyrum-labs/svelte/breadcrumbs), via canon-manifest.yaml's owns[].tier lookup. Any pairing where imported_rank >= importer_rank is a finding.

The gate's refusal pattern (verbatim)

When the gate fires, it emits one finding-line per cross-tier import plus a fixed rule-summary block. This is the contract between the gate's stderr output and this doc — they must read identically. The fixed block:

    Rule: atom→nothing canon | molecule→atom | organism→molecule+atom | template→organism+molecule+atom | page→any
    Canon: https://canon.gyrum.ai/guidelines/canon-tier-composition
    Fix: refactor to compose only from strictly-lower tiers; OR (if the lower-tier primitive is missing) author it via canon-tier Path-A (warp#3100).
    Sister gates: canon-tier (warp#2281/2399/3098), canon-page-shape (warp#3707), canon-url-resolves (warp#3722); companion: warp#3708 (tier-4 template invert).

Each finding line is shaped:

    <file> (<importer_tier>) imports <import_spec> (<imported_tier>) — peer-or-upward; rule allows only strictly-lower-tier imports

This guideline is the canonical destination cited in the gate's Canon: line; the URL must resolve so the rule body is one click from the gate output.

Why

Today's empirical evidence: V123 of the canon-Phase-1.x lift (warp#3825) surfaced that <gy-filter-chip> — a structurally-named "molecule" canon primitive — open-coded its own 6px circle as <span class="dot"> inside the molecule body rather than composing the existing <gy-dot> atom. The bespoke-dot anti-pattern passed every prior canon gate (the file was correctly placed under .../molecules/...; tokens were correctly used; the molecule shape was correctly published) yet violated the composition discipline the atomic-design hierarchy assumes: a molecule is a composition of atoms, not a re-derivation of them.

Three properties make this failure mode load-bearing:

  1. Silent re-derivation. A bespoke <span class="dot"> inside a molecule renders identically to a <gy-dot> atom at first glance. No smoke spec catches it because the output DOM is "valid HTML"; no token gate catches it because the var(--*) values match; no design-canon assertion catches it because the rendered pixel grid is correct. The drift is structural — the molecule now ships its own atom-shaped grammar parallel to the canon atom.
  2. Scales with molecule count. Once one molecule re-derives an atom, the next molecule reviewer reads "the existing pattern". The atom layer becomes invisible to consumers of the molecule layer; future fixes to <gy-dot> (a size token bump, an accessibility-label change) silently bypass the bespoke instances. This is the same shape canon-page-shape (warp#3707) retires at the page-template boundary — silent precedent eats the canon contract.
  3. Sister-shape across all five tiers. The narrow "page imports ≤1 template" rule (warp#3098) already enforces the same composition discipline at the page-template boundary. Without this broader rule, the discipline holds at tier-5↔tier-4 but leaks everywhere else: tier-3 (organism) re-deriving tier-1 (atom) primitives, tier-2 (molecule) re-deriving tier-1 (atom) primitives, etc. This gate is the generalisation.

The fix: every canon-author file imports only from strictly-lower tiers. When a lower-tier primitive is genuinely missing (the <gy-dot> you need does not yet exist), author it in the same PR via canon-tier Path A (warp#3100) — do not work around the gap with a bespoke re-derivation.

Examples — passes

Atom — HTML primitives + tokens only

// packages/components/src/atoms/dot/dot.ts — atom (rank 1)
import { LitElement, html, css } from 'lit';
import '@gyrum-labs/tokens';  // tokens always allowed (NOT a tier)

export class GyDot extends LitElement {
  static styles = css`
    :host {
      display: inline-block;
      width: var(--gy-dot-size, 6px);
      height: var(--gy-dot-size, 6px);
      border-radius: 50%;
      background: var(--gy-dot-color, currentColor);
    }
  `;
  render() { return html`<slot></slot>`; }
}
customElements.define('gy-dot', GyDot);

The atom imports only lit (HTML primitive substrate) and @gyrum-labs/tokens (below-hierarchy). No canon-tier import. Gate clears.

Molecule — composes the atom

// packages/components/src/molecules/filter-chip/filter-chip.ts — molecule (rank 2)
import { LitElement, html } from 'lit';
import '@gyrum-labs/components/atoms/dot/dot.js';  // atom (rank 1) — strictly lower

export class GyFilterChip extends LitElement {
  render() {
    return html`
      <button>
        <gy-dot></gy-dot>
        <slot></slot>
      </button>
    `;
  }
}
customElements.define('gy-filter-chip', GyFilterChip);

The molecule imports the atom (rank 1 < rank 2). Gate clears. This is the fix for the warp#3825 bespoke-dot anti-pattern: rather than open-coding <span class="dot">, the molecule composes <gy-dot>.

Organism — composes atoms + molecules

// packages/components/src/organisms/filter-bar/filter-bar.ts — organism (rank 3)
import { LitElement, html } from 'lit';
import '@gyrum-labs/components/atoms/icon/icon.js';            // atom (rank 1)
import '@gyrum-labs/components/molecules/filter-chip/filter-chip.js';  // molecule (rank 2)

export class GyFilterBar extends LitElement {
  render() {
    return html`
      <div>
        <gy-icon name="filter"></gy-icon>
        <gy-filter-chip>Status: Open</gy-filter-chip>
        <gy-filter-chip>Owner: Me</gy-filter-chip>
      </div>
    `;
  }
}
customElements.define('gy-filter-bar', GyFilterBar);

Atom (1) and molecule (2) imports, both strictly below organism (3). Gate clears.

Template — composes atoms + molecules + organisms

<!-- packages/svelte/src/templates/ProjectsListTemplate.svelte — template (rank 4) -->
<script lang="ts">
  import GyPageHeader from '@gyrum-labs/svelte/molecules/PageHeader.svelte';   // molecule (2)
  import GyFilterBar from '@gyrum-labs/svelte/organisms/FilterBar.svelte';    // organism (3)
  let { title, items } = $props();
</script>

<GyPageHeader {title} />
<GyFilterBar />
<slot {items} />

Molecule (2) and organism (3) imports, both strictly below template (4). Gate clears.

Page — composes everything below

<!-- src/routes/projects/+page.svelte — page (rank 5) -->
<script lang="ts">
  import { GyPage } from '@gyrum-labs/svelte';                                // tier-5 primitive
  import ProjectsListTemplate from '$lib/components/canon/templates/ProjectsListTemplate.svelte';  // template (4)
  let { data } = $props();
</script>

<GyPage>
  <ProjectsListTemplate title="Projects" items={data.projects} />
</GyPage>

Template (4) import, strictly below page (5). Gate clears. (Per canon-page-shape@v1, the page also roots in the tier-5 <GyPage> primitive — sister rule, enforced by the canon-page-shape-gate.)

Examples — fails

Molecule re-derives an atom (the warp#3825 anti-pattern)

// packages/components/src/molecules/filter-chip/filter-chip.ts — molecule (rank 2)
// FAILS — open-codes the dot grammar instead of composing <gy-dot> (rank 1).
import { LitElement, html, css } from 'lit';

export class GyFilterChip extends LitElement {
  static styles = css`
    .dot {
      display: inline-block;
      width: 6px;
      height: 6px;
      border-radius: 50%;
      background: var(--gy-dot-color);
    }
  `;
  render() {
    return html`
      <button>
        <span class="dot"></span>  <!-- bespoke; should be <gy-dot> -->
        <slot></slot>
      </button>
    `;
  }
}

The file is correctly placed under .../molecules/... and uses var(--*) tokens correctly, yet the rendered <span class="dot"> is a re-derivation of the <gy-dot> atom. The gate doesn't detect the re-derivation directly — but it DOES detect the absence of any tier-1 import in a tier-2 file that visually contains an atom-shaped element. The fix is composition: import @gyrum-labs/components/atoms/dot/dot.js and render <gy-dot> inside the molecule.

(Note: the gate's primary detection is import-based — it fires on peer/upward imports. The "missing lower-tier import" shape above is detected indirectly via the canon-tier sister gates (warp#3098, the duplicate-of-canon detector) plus PR-author discipline. This guideline names the failure mode so reviewers catch it even when the gate's import-scan does not.)

Molecule imports molecule (peer)

// packages/components/src/molecules/breadcrumbs/breadcrumbs.ts — molecule (rank 2)
// FAILS — peer-tier import.
import '@gyrum-labs/components/molecules/filter-chip/filter-chip.js';  // molecule (rank 2) === rank 2

imported_rank (2) >= importer_rank (2) — peer; gate refuses. Molecules compose atoms, not each other. If the breadcrumbs molecule genuinely needs the filter-chip's grammar, the shared shape is either (a) an atom both molecules compose, or (b) an organism that composes both molecules.

Atom imports anything canon

// packages/components/src/atoms/dot/dot.ts — atom (rank 1)
// FAILS — atoms refuse any canon import.
import '@gyrum-labs/components/molecules/filter-chip/filter-chip.js';  // molecule (rank 2) — upward

Atoms sit at the base of the hierarchy; importing a molecule (or any other tier) is upward. The rule "atom imports nothing canon" exists precisely because the atom layer is the leaf — anything above it has already used it; reaching back up creates a cycle.

Organism imports a template (upward)

<!-- packages/svelte/src/organisms/FilterBar.svelte — organism (rank 3) -->
<!-- FAILS — upward import: organism (3) imports template (4). -->
<script lang="ts">
  import ProjectsListTemplate from '@gyrum-labs/svelte/templates/ProjectsListTemplate.svelte';  // template (rank 4)
</script>

imported_rank (4) >= importer_rank (3) — upward. The organism is supposed to compose lower-tier primitives (atoms, molecules) into a unit the template arranges. Reaching up to the template inverts the hierarchy and makes the template-organism dependency a cycle.

Path forward when the lower-tier primitive is missing

Path A — author the lower-tier primitive in the same PR (default)

When your tier-N file needs a tier-(<N) primitive that does not yet exist, author the primitive in the same PR per canon-tier extension discipline (warp#3100):

  1. Author the missing primitive at the appropriate path (packages/components/src/atoms/<name>/... or packages/svelte/src/atoms/<name>/...).
  2. Add the primitive to its own story + visual regression suite.
  3. Register the primitive in canon-manifest.yaml's owns[].
  4. Consume the new primitive from your tier-N file.
  5. Ship canon-side + consumer-side in the same PR so the persona reviewer (Marcus in particular) sees the contract end-to-end.

This is the dominant path. The primitive is meant to be authored under real consumer pressure, not speculation. Same shape as canon-page-shape's Path A (extend <GyPage> in the same PR when the page needs a new geometry hook) and canon-extension-discipline's Path A (author the canon primitive in the same PR when the consumer would otherwise carry bespoke layout grammar).

Path B — sister-ticket on canon-tier (when authoring tier is deeper than consuming tier)

When the missing primitive is at a tier deeper than the file you are authoring and the deeper-tier work would itself need review (e.g. an atom primitive with a novel visual contract that deserves its own dedicated review), file a sister ticket on gyrum-labs/canon or gyrum-labs/gyrum-ui and tag your current ticket blocked-by:warp#<sister>. The sister PR ships the primitive with its own review; your tier-N PR waits and consumes it once it merges.

This is sister-shape to the canon-extension-discipline Path B and to the global "authoring tier vs consuming tier" decision rule in CLAUDE.md (warp#3100 — Authoring new canon — in-ticket vs sister ticket). The decision rule:

  • Authoring tier at or above consuming tier → Path A (in-ticket).
  • Authoring tier below consuming tier → Path B (sister ticket).

The deeper the tier of the authored primitive (e.g. an atom), the more likely it warrants Path B because atoms have many future consumers and their visual contract deserves dedicated review.

Audited override

gyrum-review-pr --skip-canon-tier-import "<reason ≥ 30 chars>" — logged to ~/.gyrum/admin-overrides.log and ~/.gyrum/findings/findings.jsonl per the audit-on-override convention. Use ONLY when the cross-tier import is genuinely unavoidable:

  • Re-export shims — a tier-N barrel file that re-exports a tier-N primitive for ergonomic consumption (packages/components/src/molecules/index.ts re-exporting from ./filter-chip/). The re-export is a peer reference by construction; the bypass names this as intentional.
  • Story / catalogue filespackages/components/src/molecules/filter-chip/filter-chip.stories.ts typically imports the sibling primitive plus higher-tier composition examples; treat the stories file as out-of-band.
  • Test files — a tier-N test importing a higher-tier primitive to assert a composition scenario; the test is documentation of the composition, not the composition itself.

The reason text MUST name (a) why this import is peer/upward, (b) why Path A (authoring the missing lower-tier primitive) doesn't fit, and (c) what makes this shape intentional rather than oversight. Generic reasons ("temporary", "will refactor later", "not in scope", "doesn't matter") are rejected by the operator audit.

v1 advisory mode

This gate ships in v1 advisory mode — findings surface as a reviewer-visible row in gyrum-review-pr's output but rc=0 keeps the gate from blocking merge. Promotion to fail-closed (rc=1 on any finding) is gated on:

  1. Baseline reaches zero. A clean-bill pass of the gate across gyrum-labs/canon, gyrum-labs/gyrum-ui, and the consumer-tier canon-mirror trees (ai-frontend/src/lib/components/canon/, warp.gyrum.ai/src/lib/components/canon/, etc.). Existing peer/upward imports lift via Path A in dedicated cleanup PRs first.
  2. Sister tier-4 template invert lands. warp#3708 (gyrum-ui — invert tier-4 templates to delegate page-geometry to <GyPage>) eliminates the duplication that would otherwise force template-tier files through a wrapped shape. Until that lands, the template-tier baseline carries forced peer-imports.

The advisory-mode-then-enforce pattern is the same shape canon-page-shape-gate (warp#3707) and canon-url-resolves-gate (warp#3722) use — surface the signal first, give the canon-author tier time to migrate (Path A), then promote to fail-closed once the baseline reaches zero.

PR body citation

Required line in the PR body when changing files in scope (any canon-author file under packages/components/src/{atoms,molecules,organisms,templates,pages}/**, any packages/svelte/src/**, or any src/lib/components/canon/**):

Guideline: canon-tier-composition@v1

The persona reviewer (ADR-115) fetches this exact version and verifies the implementation against this text. Bumping the version is a separate PR through gyrum-revise-guideline canon-tier-composition that includes a regression-corpus re-run.

Revision process

This document is versioned. Updates ride gyrum-revise-guideline canon-tier-composition (separate devtools wrapper, separate ticket). Hand-edits without that wrapper skip the regression suite. The regression corpus at regression-corpus/guidelines/canon-tier-composition/ includes one fixture per pass / fail example above plus an end-to-end fixture that runs the gate against a synthetic PR diff containing five canon-author files of mixed strictly-lower / peer / upward / atom-imports-canon shapes.

References

  • warp#3826 — canon-tier-import-gate implementation (devtools PR#854; the structural enforcement this doc accompanies).
  • warp#3954 — this guideline's live ticket (companion meta-doc to warp#3826 gate).
  • warp#3825 — gyrum-ui V123 lift surfacing the bespoke-dot anti-pattern that drove this gate's authoring.
  • warp#3823 — the broader "structurally-named, implementationally-bespoke" finding-cluster (parent of warp#3825).
  • warp#2281 / warp#2399 / warp#3098 — canon-tier-gate (sister structural gates: duplicate-of-canon detection + the narrow "page imports ≤1 template" rule this guideline generalises).
  • warp#3707 — canon-page-shape-gate (sister gate: every +page.svelte roots in <GyPage>).
  • warp#3722 — canon-url-resolves-gate (sister gate: canon URLs in PR bodies / refusal messages must resolve; the very gate this doc exists to satisfy for warp#3826).
  • warp#3708 — gyrum-ui tier-4 template inversion (prereq for promotion to fail-closed; eliminates forced template-tier peer-imports).
  • warp#3100 — canon-extension discipline (Path A in-PR vs Path B sister-ticket; the global decision rule).
  • warp#3717 — canon-doc frontmatter extension (T-A; the triggers / gate / prompt_snippet shape this doc adopts).
  • ADR-083 — defense-in-depth rule promotion (the advisory-then-enforce pattern this gate rides).
  • ADR-115 — principle-aware reviewers (cite this guideline's version).
  • ADR-117 — module guidelines (the substrate this doc rides).
  • ADR-185 — triple-derive contract (the gate's refusal message + this doc + the regression corpus all derive from one source).
  • canon site: https://canon.gyrum.ai/
  • canon guidelines registry: https://canon.gyrum.ai/data/guidelines-manifest.json