Build with the Astro Stack
Plan Your Foundry gives the stack-neutral route. This page applies that
route to one repository composition: Markdown knowledge in a content/ tree, an Astro site on
GitHub Pages, TypeScript and Zod content contracts, Vitest validation, and shared
@galaxy-foundry/* mechanics from
foundry-lib.
The page owns the composition seam: the repository layout, order of work, instance-supplied
policy, and end-to-end checkpoints. foundry-lib owns package behavior and APIs. A concrete
Foundry owns its domain knowledge, kinds, vocabularies, renderers, targets, and acceptance policy.
All three current instances use this composition. The
TDA Bioinformatics Foundry is the active
reference build: it consumes these shared contracts directly and feeds hardening improvements
back into the earlier instances, especially Statistical Genomics.
From pattern commitment to implementation seam
What a Foundry Needs defines the pattern-level boundary. The
foundry-lib shared-substrate guide
explains which mechanics have earned a shared package, and its
package boundaries
page is canonical for what those packages can truthfully own.
| Pattern concern | This Astro composition | Canonical detail |
|---|---|---|
| Knowledge Base (KB) | Astro renders the content tree; the instance supplies identity, routes, corpus, and visual contract. | content-reader boundary |
| Mold (typed actions) | Instance-defined Zod kinds make action boundaries and reference requirements checkable. | foundry-lib integration guide |
| Corpus-First, Not Invention-First | Source directories, authored derivatives, licenses, and refresh posture remain instance content and policy. | Plan Your Foundry |
| Progressive disclosure | The shared reference vocabularies combine with instance-defined reference kinds and placement support. | reference-contract package |
| Casting and Provenance | The instance resolves and renders; shared helpers handle placement, reconciliation, licensing, and the provenance record. | cast architecture |
The domain’s external check remains instance-owned. Part 8 asks what can produce an independent verdict without pretending Astro or a shared package can answer that domain question.
Keep the handoffs explicit
- Package integration: use the
foundry-libGetting Started guide for current prerequisites, installation, package APIs, and focused adoption guides. - Stack vocabulary:
note,kind,shape,companion, andcollectionbelong to this implementation and are pinned in the astro-stack-glossary, not the pattern glossary. - Running evidence: compare the galaxy-workflow-foundry, the TDA Bioinformatics Foundry, the statistical-genomics-foundry, and the-diff rather than copying inventory claims into this guide. TDA’s domain verdict is still evolving; its reproducible environments and evidence-bearing replication are current teeth, not a substitute label for scientific correctness.
How to use the checklist below
The checklist is rendered verbatim because it is a repository composition recipe: named paths,
commands, and checkpoints precise enough to follow by hand or hand to an agent. Work it top to
bottom and expect to loop back. When it names package behavior, the linked foundry-lib
architecture and package documentation are canonical. Toolchain versions live in one
REFERENCE STACK; current @galaxy-foundry/* ranges stay canonical in foundry-lib and are
adopted by capability rather than mirrored here. The closing INSTALLED / MECHANICAL / YOURS
grading keeps shared machinery distinct from instance policy and domain knowledge.
STANDING UP A FOUNDRY — WORKING CHECKLIST
This is prescriptive: it maps the abstract pattern onto ONE concrete implementation — an
Astro static site on GitHub Pages with schemas, validation and registries. The abstract "Plan Your
Foundry" page stays stack-agnostic; this one names packages, versions, paths and commands.
Nothing here is the only way to satisfy the pattern — it is the one worked example.
Order of work: the glossary comes FIRST (Part 1) — it is the KB's highest fan-in reference
and the shared vocabulary every cast carries, so pin it before anything answers to it. Then
the engineering substrate (Parts 2–4: reading surface, frontmatter contract, registries +
validator + CI), which is inherited — partly INSTALLED from @galaxy-foundry packages and
partly re-stood-up as known machinery, a split "The shared substrate" below draws and the
closing section grades. Then the rest of the domain knowledge (Parts 5–8: corpus, Molds, the
external check, composition), which only your domain can supply.
Do not read Parts 2–4 as one all-at-once site launch. The first reviewable loop is deliberately
small: the glossary, one explicit collection row, one kind with one real note, one content-reader
binding, the shared shell/frame, a built-output contract, and CI running a frozen install plus that
contract. Expand the taxonomy only after this slice proves its routes, links, emitted styles and
search coverage.
PART 1 — DEFINE THE VOCABULARY FIRST
A Foundry names things that did not exist before it, and the glossary is the KB's highest
fan-in reference — every cast carries it, and a human learns the domain from it.
Pin it first, in one glossary: content/meta/glossary.md (the deliberate loose-doc exception
inside the design-record directory, excluded by name from the `meta` collection and rendered by
Part 2's render-vault-doc helper) that the rest of the corpus answers to.
- Bootstrap it with the SHARED SUBSTRATE TERMS before coining a single domain term. Every
Foundry glossary carries these eight, inherited from the pattern glossary (the authority —
copy its definitions, do not re-invent them):
Knowledge Base (KB), Mold, Reference, Cast, Casting, Skill artifact, Target, Provenance.
Use these EXACT headwords so a term means the same thing in every instance.
- THEN the STACK terms, because adopting a stack means adopting its words. This one brings eight,
pinned in the pattern site's Astro-Stack Glossary and inherited exactly as the eight above are —
copied, not re-coined:
Note, Kind, Shape, Companion, Collection, Kind context, Kind manifest, Tag/Facet.
This is the tier an instance is likeliest to get wrong, and the reason is that these terms feel
like yours: you will type `kind` fifty times a day in your own repository, and re-deriving what
it means locally is the natural thing to do. It is also the tier that does not hold for a Foundry
on another stack, so it is not the pattern's to demand.
Note / Kind / Companion define each other in that order — a note declares a kind, a kind fixes
whether its notes are files or directories, and only a directory-shaped kind can have
companions. Pin all three or none: `companion` used before `note` is pinned degrades into "some
file near another file", which cannot support one consistent layout contract.
One collision to resolve out loud rather than inherit: `kind` names BOTH a note's kind and a
reference's kind, and both are closed vocabularies you will write down. Say which you mean
wherever both are in scope.
- THEN coin the domain terms, corpus-first — add a term when a Mold actually needs it, not
before.
- One authoritative sentence per term: abstract meaning first, a brief (e.g. …) where it has a
concrete realization.
- One word per concept; retire the rivals. Resolve singular/plural, capitalization, and
half-synonym drift here before it spreads.
- ONE GLOSSARY, ordered for a reader learning YOUR domain — not partitioned by where each term
came from. Terms arrive from three places (the pattern, your stack, your own coinage), but that
is PROVENANCE, and provenance is a property of an ENTRY rather than a reason to split the page.
Record it per term the way a kind records `layer` instead of living in a separate directory of
substrate kinds: one marker, and a consumer asking what transferred computes the answer instead
of trusting your headings. A reader meeting your vocabulary for the first time needs it grouped
by MEANING; they did not arrive wanting a tour of which upstream supplied what.
The marker still carries everything the older "keep them scoped apart" rule was actually after:
an inherited term keeps its inherited DEFINITION, and a term you coined must not be able to pass
as one the pattern handed you. Both hold wherever the entry sits on the page. What you must not
do is redefine an inherited term to mean something local — that is the real failure, and moving
it to a different section was never what prevented it.
- Make the glossary the tie-breaker: where two pages disagree, the glossary wins — state it
outright. Casting copies glossary entries VERBATIM.
REFERENCE STACK (toolchain versions checked 2026-08-06 — bump coupled peers together)
Toolchain Node 24 (engines: >=22.12), pnpm via corepack (lockfile committed, the version
pinned by `packageManager` in package.json), TypeScript ^5.7.0
Framework astro ^7.1.6 (static output), @astrojs/check ^0.9.4, vite ^8.0.0 (dev)
Styling tailwindcss 4.3.3 + @tailwindcss/vite 4.3.3 + @tailwindcss/typography
^0.5.19; @fontsource/atkinson-hyperlegible ^5.2.8
Markdown marked ^18.0.2 (standalone render) + a thin src/lib/marked.ts wrapper;
@astrojs/markdown-remark ^7.2.2 for the site's own pipeline (see Part 2)
Math remark-math ^6.0.0 + rehype-katex ^7.0.1 + katex ^0.16.45
Search astro-pagefind ^2.0.1 (integration) + pagefind ^1.5.2 (dev)
Schema zod ^4.4.3 (import `z` from 'zod', NOT from 'astro:content')
YAML js-yaml ^4.1.0 + @types/js-yaml ^4.0.9
Validator vitest ^4.1.10
Foundry Install @galaxy-foundry packages BY CAPABILITY using foundry-lib's current
Getting Started guide and package metadata. Releases, APIs and peer compatibility
are canonical there, not mirrored here. Commit the chosen ranges in
site/package.json and the resolution in the lockfile. The substrate section below
owns which capability each package supplies and when an instance needs it.
Layout repo root holds site/ and content/ as siblings; paths under content/ resolve
from the site/ cwd as '../content/…'. content/meta/ holds flat `meta` design
records; glossary.md is excluded by name and an ops log may remain a second
explicit loose-doc exception.
This table is the source of truth for this worked example's TOOLCHAIN versions. The substrate
table below is the source of truth for package CAPABILITIES and adoption timing. foundry-lib is
the source of truth for @galaxy-foundry package versions and APIs. The parts below name MODULES
and WIRING; they do not mirror dependency ranges.
"Coupled peers together" is not a style preference: `zod`, kind-manifest and kind-schema move
TOGETHER, in one commit, because the schema packages peer on one zod major and inspect the same
shapes the instance builds. See the zod bullet below for what a split bump does. Unrelated
@galaxy-foundry capabilities do not need ceremonial lockstep bumps.
THE SHARED SUBSTRATE — INSTALL IT, DO NOT COPY IT
The baseline reader-and-contract substrate is not yours to author. Its capabilities ship from
jmchilton/foundry-lib under the @galaxy-foundry scope. Install each capability when the instance
reaches it, using foundry-lib's Getting Started guide and package metadata:
@galaxy-foundry/license-policy the license → redistribution table + loader
@galaxy-foundry/kind-manifest the manifest format: reader + zod-shape deriver,
and the LAYOUT vocabulary a manifest carries
@galaxy-foundry/kind-schema the kind contract: what a kind IS, how one is assembled,
path→kind routing, the kind.md loader, the companion check
@galaxy-foundry/content-reader collection-backed file selection, note ids, wiki-link map
registration, and parsed/raw Markdown link binding
@galaxy-foundry/reference-contract the four behavior vocabularies every instance inherits
@galaxy-foundry/site-kit the reading shell, reference view and the behavior contracts
for navigation, note framing, tags, theme state, search and
deployment base
@galaxy-foundry/tag-registry the meta_tags.yml format and its accessors
@galaxy-foundry/wiki-links the [[Target]] grammar, path/term slugging, exact resolver,
parsed- and raw-Markdown transforms
Additional packages are capability-specific, not part of the baseline install:
@galaxy-foundry/cast deterministic casting assembly, reference placement,
reconciliation, licensing and provenance. Adopt when the
first real Mold is ready to cast.
@galaxy-foundry/audit-citations scholarly-citation extraction, evidence capture and replay.
Adopt only when citation auditing is part of the instance's
acceptance policy. Experimental and deliberately not a
generic external-check framework.
The package count is not a completeness score. A Foundry without citation auditing does not install
audit-citations; a reader-only vertical slice need not install cast. Capability-based adoption is
the package model, and the lockfile is the record of what this instance actually selected.
WHAT EACH PACKAGE DOES NOT DO is the half that matters. The package ships the FORMAT or the
MACHINERY; your instance supplies the CONTENT. Get the line wrong in either direction and you
have either a dependency dictating your domain or a copy that drifts:
license-policy Ships the table — which SPDX id permits verbatim carry. It names policy,
license-file obligation, copyleft and obligations; it names NO casting mode.
A license constrains what expression a note carries, not how a bundle places
it. `declaresVerbatimCarry(derived)` separates pass-through/quoted content
from Foundry-authored own-words prose before the row is applied. You keep
license COHERENCE: how a note's declared posture, license file and any
vendored source must agree. Those rules vary with the instance's note shape.
The package answers "what does this license permit", never "is this note
coherent with its license".
Load with bundledPolicy() — the table travels with the dependency, so a
content tree no longer has to CARRY it to be validated or cast against it.
kind-manifest Ships the format, the reader, and the deriver. You keep which kinds exist
and how your registries resolve into the context they are built against.
The package takes BUILT SHAPES rather than knowing that context. It also owns
the LAYOUT VOCABULARY —
`NoteShape`, `Companion`, `CompanionRequirement`, `CompanionDisposition` —
for the same reason it owns `KindLayer`: a manifest carries these values
across a repository boundary, so the closed sets are part of the FORMAT.
kind-schema re-exports them rather than declaring a second copy.
kind-schema Ships the CONTRACT and the machinery over it: `KindDefinition` (`build`,
optional `refine`, plus the `shape` + `companions` declaration it EXTENDS),
the `kindDefiner`/`defineKind` identity helper, `assemble` for one kind,
`buildKindUnion` for a mixed corpus, the `manifestKinds` bridge into
kind-manifest, path→kind routing (`./collections`), the `kind.md` reader
(`./docs`), and `companionsOf`/`checkCompanions` over a directory listing
you already have — PURE, no I/O, which is what keeps the main barrel
importable from browser code. Ships NO KINDS —
and, less obviously, NO CONTEXT. The context is the SEAM for the base
envelope, registries and field primitives a local kind may draw from, so
`KindDefinition` is generic over it and you alias the parameter away once.
Ships no collection TABLE either — that names your directories.
content-reader Ships the collection-backed filesystem walk, note ids, wiki-link map
registration, and parsed/raw Markdown transforms as ONE composition. You
keep the collection table, content path, route targets, extra loose targets,
and the vocabulary that makes a note addressable by aliases. Frontmatter is
read only when aliases or metadata-bearing targets opt in. The package owns
applying those aliases and its deterministic collision rules; the instance
does not rebuild the walk in order to learn its own vocabulary.
Ships no Astro collection exports or schemas. Keep those explicit so
`entry.collection` continues to discriminate precise frontmatter types.
reference-contract Ships used_at / load / modes / evidence. You keep `kinds` — exactly the half
that varies by domain. Inherit the four whole: a term you have not used YET
is inheritance, not drift. `modes` ships exactly two deterministic placement
rules — verbatim and sidecar — backed by concrete renderers. Narrow the
group to the modes your targets implement; the validator must reject
anything the caster cannot reproduce byte for byte. A narrowed group is
AUTHORED, so it rejoins the dead-vocabulary check.
site-kit Ships the shared MARKUP, behavior-critical component styles and the rules
inside them — active navigation, pre-paint theme state, Pagefind body
coverage, overflow behavior, the invariant ContentNote frame, TagChips, the
reference-contract view, deployment base handling, and SPECIMENS: named
props for every case its components claim to handle, with the surface each
case may render on. You keep the IDENTITY: both names, the description, the
repository, the list of destinations, and how many of them fit on the bar
before the rest go under "More". That last one is set by measuring this
instance's wordmark and links, not by copying another site's count.
Ships no complete GLOBAL THEME. It writes Tailwind utility classes, carries
structural styles beside the components that need them, and NAMES custom
properties and global classes your stylesheet defines. The palette is the
part of a reading surface that is genuinely an instance's. Point Tailwind at
the package source and use its emitted-CSS checks; both missing halves build
cleanly and render wrong.
Ships ONE value it does NOT take a prop for: the width of the reading column.
Treat that as part of the shared shell; a genuinely different measure is a
reason to revisit the contract rather than add an unused configuration prop.
Ships the BASE RULE — `shellBase`, which normalizes a deployment base into
the prefix every href in your site hangs off. Not a general utility that
happens to live here: the shell has to resolve its nav against a base, so
the definition is already its, and a page that computes its own is a second
answer to a question the chrome has already answered. Reads NO environment
to do it — you pass the value in.
tag-registry Ships the format and its three rules (see Part 4). Ships NO VOCABULARY:
facets are the browse axes of one domain, so there is no useful universal
subset to inherit.
wiki-links Ships the grammar, path and term slugging, the resolver, and parsed- and
raw-Markdown walks. It ships NO LINK MAP by itself: which notes exist and
what each is addressable by is instance data. content-reader owns registering
that data for collection-backed sites; a lower-level direct consumer uses
`fileSlug` and `slugify` so the path and authored-target halves cannot drift.
cast Ships `castMold`, cast-contract parsing, the generic skill-document shape,
Kind-layout companion expansion, deterministic placement/reconciliation and
provenance. You keep the kind declarations, target policy, corpus index and
hooks: domain skill sections, non-verbatim renderers, extra bundle files and
external checks. Pass the Kind table as `kindLayouts`; do not translate it
into another companion policy. Errors and drift return as values; your
caller decides how they affect the process. Do not disguise instance
vocabulary as substrate.
audit-citations Ships one replayable scholarly-citation audit and its strict wire formats.
You keep source selection, trusted hosts, artifact kinds and the acceptance
gate. It is experimental; its citation-specific candidates and verdicts are
not a base schema for every domain's external check.
COMPOSE, DO NOT RE-EXPORT. Where your instance supplies a half, write ONE composition point that
keeps the signature your callers already use, and let every other call site import from the
package directly:
export function loadReferenceContract(p = findReferenceContractPath()): ReferenceContract {
return buildReferenceContract({ kinds: loadInstanceKinds(p) });
}
Done this way, adoption touches the composition point rather than every consumer. Re-export only a
TYPE a caller must name to build an options object. A barrel
re-exporting the package's whole API is a name list that drifts from the package's, and one more
place to look.
INTEGRATION FAILURES THAT WILL BITE:
- A package can install correctly and still not work, if something other than the module resolver
has to find its files. site-kit is the case in this set: Tailwind does not scan node_modules, so
its markup contributes no CSS until your stylesheet points at it explicitly. Part 2 has the line
and the assertion that proves it. Treat the shape as general — when a package ships anything a
BUILD TOOL rather than an IMPORT has to reach, expect a silent default and go looking for it.
- Declare the dependency in the package that IMPORTS it — INCLUDING site/. A package resolving a
shipped YAML asset relative to import.meta.url, reached only through a LINKED workspace package,
gets inlined into an SSR chunk by Vite, and the path then resolves against the chunk directory
— site/dist/.prerender/chunks/ on 7.1.6 — instead of node_modules. It passes every test and
every typecheck, and fails at `astro build`. The Part 2 anchoring rule is the same mechanism
seen from the consumer's side; this is the half that bites while you are packaging.
- kind-manifest pins `zod` as a PEER dependency at ^4, because the deriver reads `_zod.def.type` —
zod INTERNALS, since zod exposes no public reflection API and the alternative is the hand-written
field table this mechanism exists to prevent. Three consequences, the last of which is what a
port actually costs:
* The peer range admits ONE major, with no v3 fallback. Supporting both was considered and
rejected: a branch on internals is code whose untested arm renders every field `any` — a
manifest that generates, validates and publishes while saying nothing. An instance upgrades
zod and these two packages in the same commit, or it does neither.
* The deriver must read the SAME zod instance your schemas were built with. A duplicated zod in
the tree renders every field `any` by the same route. Keep one zod, and keep it in range.
* What zod 4 renamed is not a detail you can skip past if you are porting a v3 instance: every
type tag moved off `_def.typeName` and changed spelling (`ZodString` → `string`), an array's
element is `element` not `type`, a literal carries `values` (plural — v4 literals may hold
several) not `value`, an enum carries `entries` (an object) not `values`, a discriminated
union now tags as plain `union`, and a `ZodRawShape`'s values type as the CORE schema, which
carries neither `.isOptional()` nor enough structure to walk. All of that is the PACKAGE's
problem, not yours — the point of installing it. What IS yours: `z.ZodObject<T, 'strict'>`
becomes `z.ZodObject<T, core.$strict>` (the second parameter is a config object in v4, not a
mode string), `z.ZodType` loses its middle `ZodTypeDef` parameter, `z.string().url()` becomes
`z.url()`, `z.ZodIssueCode.custom` becomes the string `'custom'`, and `z.ZodIssue` becomes
`z.core.$ZodIssue`. `superRefine`, `RefinementCtx`, `discriminatedUnion` and `.strict()` all
survive unchanged, so the schema BODIES mostly do not move.
- The zod bump and the astro bump are ONE COMMIT, not two, and the reason is worth knowing before
you plan the work: astro 5 types `defineCollection`'s schema as `ZodType<any, ZodTypeDef, any>`,
which a zod-4 schema does not satisfy — so the intermediate state does not typecheck in either
order. The runtime is worse than the types suggest: astro 5 reads `parsed.error.errors[0]`, which
v4 renamed to `.issues`, so the FIRST invalid note throws a TypeError instead of reporting which
field of which note failed. A validator that reports "cannot read property of undefined" where it
used to name the field has not caught the note — it has lost the ability to.
- A caret on a 0.x version does not cross a minor. `^0.3.0` will not take 0.4.0, so every substrate
range moves BY HAND on a minor release; there is no `pnpm update` that will do it for you, and
nothing fails while it is not done — you keep the old package.
- A local package link can survive the release it was waiting for. Before a consumer PR leaves a
worktree, remove its `overrides` and every `link:` resolution, regenerate the affected lockfile
from the published range rather than editing a resolution by hand, then run
`pnpm install --frozen-lockfile` and inspect `pnpm list --depth 0`. The manifest naming a release
while node_modules or the lockfile still names a sibling worktree is not published-package
adoption; it is a build whose success depends on a directory outside the repository.
- If your repo is a WORKSPACE, state ONE version of each package across every member. A range that
admits the current release does not mean the lockfile resolved it; two installed versions can
leave one member resolving a build that predates an export without producing a version-conflict
error. Bump the ranges as a set, inspect the resolved tree, and check what is actually on disk.
THE RULE FOR WHAT MOVES THERE: share a stable mechanism or format, not a domain decision that only
looks reusable. The base note envelope, kind schemas, reference kinds, license-coherence rules and
domain renderers stay local until a real cross-instance contract exists. Duplication is evidence
to investigate; it is not by itself a package boundary.
Three boundaries are especially important:
link vocabulary What a note is addressable BY is domain. The instance supplies its alias
vocabulary; content-reader owns walking the admitted notes, registering the
addresses once, and applying collision precedence.
domain furniture ContentNote owns the invariant frame and TagChips owns the shared tag markup.
The things that render a Mold's phases, a paper's provenance, or a package's
software facts remain domain. They enter the frame through slots rather than
by copying the frame into one component per kind.
the palette The shell names theme roles without supplying instance values. A token the
instance forgets renders unstyled rather than failing, so assert the
package's style-gap helpers against emitted CSS.
PART 2 — STAND UP THE READING SURFACE
The corpus renders to a static Astro site under site/. Stand it up first (empty but for the
glossary): it is where every later artifact is read, and `astro build` is the fastest signal
that a note is well-formed.
- `pnpm create astro@latest site` — static output. Bind the base to a NAME in
site/astro.config.mjs, and use the name everywhere else in that file:
const base = '/<foundry-name>';
export default defineConfig({ site: 'https://<user>.github.io', base, … });
Outside the config, reuse `import.meta.env.BASE_URL`; never re-hardcode the base. Inside it that
variable does not exist yet, which is the whole reason for the `const`. Repeating the literal in
the config and wiki-link options creates two deployment bases that can drift without an error.
- Wire the stack's render deps into astro.config.mjs (packages/versions: the table above):
markdown.processor = unified({ // from '@astrojs/markdown-remark'
remarkPlugins: [[remarkWikiLinks, { base }], …your domain plugins],
})
compressHTML = true
vite.plugins += tailwindcss() // @tailwindcss/vite
integrations += [pagefind()] // astro-pagefind
The wiki-link plugin is substrate. Other remark/rehype plugins are selected by the corpus: add
math, citation or source-format transforms only when the domain content requires them.
Set the body font to Atkinson-Hyperlegible — a human reader is one of the two audiences, so
legibility is a substrate concern.
- ASTRO 7 RUNS NO REMARK OR REHYPE PLUGIN UNLESS YOU ASK. It renders markdown with Sätteri, a Rust
pipeline, and `markdown.remarkPlugins` — the astro ≤6 spelling, and the one every tutorial still
shows — is not the wiring any more, which is why the block above names `markdown.processor`
instead: opting the unified pipeline back in explicitly. For this stack that is not a nicety —
`[[wiki-links]]` is supplied by the installed wiki-links package, and Part 4's link check asserts
against a grammar the renderer would no longer be applying.
With `@astrojs/markdown-remark` absent the build fails and names the package to install. With it
present, legacy `markdown.remarkPlugins` wiring produces a deprecation warning and is migrated
onto a compatible unified processor. Write `markdown.processor` directly. The arrangement that
still loses plugins quietly is setting `processor: satteri(…)`
and leaving `remarkPlugins` beside it: the shim will not migrate onto a non-unified processor,
so you get a warning and no plugins. Do not spell both.
- `compressHTML: true` is the pin in this part that DOES fail silently, and it is the one to spend
the paranoia on. 7 defaults it to 'jsx', which drops the whitespace between inline elements that
sit on their own source lines. Removing the pin on a finished site builds clean and glues prose
together on every page — nav rendering as `The PatternThe CaseInstances`, and the spaces around
inline links gone. Nothing goes red. Worth a comment in the config saying so, because a future
reader deleting it gets no other signal.
Both pins share a lesson bigger than either: ACCEPT A BUMP ON THE RENDERED PAGE, NOT THE BUILD.
A green `astro build` is evidence about the build, and these are defaults that change the OUTPUT.
Keep the pre-bump dist/, then diff the rendered TEXT of every page against it: strip the tags,
collapse runs of whitespace, compare. A glued word merges two tokens into one, so it shows up as
a changed line. Count the resolved `[[link]]` anchors on both sides too — equal counts is the
check the processor migration actually needs.
THE STYLESHEET IS OUTPUT TOO, and the markup diff will not show you it changed. Tailwind scans
source text, comments included, so a comment can keep an otherwise-unused class alive. Diff the
built stylesheet as a set of rules, not as a file: the filename carries a content hash and Astro's
scoped-style ids change whenever a component changes path, so both differ for reasons that are
not differences. Normalize those two, then compare.
Strip each tag to the EMPTY STRING, not to a space. Replacing `</a>` with a space re-supplies
the very whitespace compressHTML dropped, and the comparison passes clean on a site whose every
page has visibly run together — which is this bullet's own failure mode, found by running it.
- Declare ONE detail route, site/src/pages/[collection]/[...slug].astro, and take its route segment
from `entry.collection` — so the collection key IS the route, by construction rather than by
everyone remembering. Keep an index.astro per collection; a browse page is a page, and they
differ. The collection table (Part 3) is then the only list of collections in the repo, and
everything that needs to know where a note lives — the Astro loader, the validator walk, the
drift checks, the wiki-link map — reads it.
One route keeps search indexing, tag placement, back navigation, title and summary behavior in
one answering site. Express genuine per-collection differences as data, and dispatch domain
furniture on `entry.collection` or the note's `type`.
Precise frontmatter does not require separate routes. Spread explicit `getCollection` results
into one array so they remain a discriminated union; mapping them through a homogeneous list
widens them to their common shape. Test that the detail route derives its segment from the entry,
every collection has a table row, and every collection carrying notes reaches content-reader.
- INSTALL the reading shell — @galaxy-foundry/site-kit — rather than writing one. What you write
is site/src/layouts/Base.astro, and it is a COMPOSITION POINT of about fifteen lines: it hands
SiteShell your identity plus your base and `Astro.url.pathname`, and receives the document
skeleton, the header with its derived navigation, and the footer. Keep every page importing
that layout; nothing else in your repo should know the shell is a package.
The identity is one object in site/src/lib/site-identity.ts — short name, full name,
description, repository, nav destinations, and how many stay on the bar. Write it out as DATA;
active state is derived by the shared shell rather than repeated as per-entry closures.
The BASE is one value in site/src/lib/site-base.ts — `shellBase(import.meta.env.BASE_URL)` —
exported for the layout AND for every page and component that builds an href. ASSERT THAT IT IS
THE ONLY MODULE READING BASE_URL, and assert the rule rather than the expression, because both
ways of getting it wrong build green. Reading it unstripped is CORRECT at a domain root, where
the strip does nothing — so dev and any root deploy confirm the bug, and only a sub-path deploy
shows the doubled slash. A stripper that trims one character too many renders no error either,
just links that land somewhere else. A page that computes the base inline renders byte-for-byte
what an imported one does, so no diff of the built site will ever show you the next copy; only
a rule read off the source will.
READ THE ENVIRONMENT IN YOUR FILE, NOT THE PACKAGE'S. The base and the pathname are props for
a reason: under vitest `import.meta.env.BASE_URL` is mirrored into `process.env` as `/`, and a
child `astro build` prefers it over your config — so a suite that lets the shell read the
environment asserts against a site nobody is shipping, and builds clean while doing it.
- POINT TAILWIND AT THE PACKAGE, AND ASSERT THAT YOU DID. Tailwind 4 does not scan node_modules,
so add to your stylesheet:
@source "../../node_modules/@galaxy-foundry/site-kit/src";
Without it every utility the shell writes is missing from the emitted CSS. The build is green,
every page renders, and the site has no layout. THE LINE CANNOT BE VERIFIED BY READING IT: a
misspelled path resolves to nothing and Tailwind says nothing — `site-kitt` builds exactly as
cleanly as the correct spelling. So assert on the OUTPUT: pick a utility the kit names and your
repo does not, and check it has a rule in the built stylesheet. `min-h-dvh` is a useful canary.
If your tests live inside the Vite root, exclude them — `@source not "../../tests"` — or the
test that names the canary is what puts the class in the CSS, and it cannot fail.
- The PALETTE stays yours. The kit ships structural component styles but no complete global theme;
it names custom properties and global classes your stylesheet defines. A token you forget
renders as an unstyled element rather than an error, so use the package's style-gap helpers on
emitted CSS.
- ACCEPT THE READING SURFACE VISUALLY with a component gallery when the instance has a real theme
or local visual components. Import `SPECIMENS` from `@galaxy-foundry/site-kit/specimens`; do not
copy the props cases from the reference app or a sibling Foundry. Render the inline groups on an
instance-owned page through the ordinary Base layout, so the package's components wear the same
palette, typography and classes as the corpus. Use `sharesPage(group)` to separate groups that
need a route of their own: `isolated` components carry document-unique ids, while `document`
components emit their own `<html>`. Give both real static routes and frame those routes from the
index. Two headers written directly onto one page build clean and leave the second one's controls
half-dead; `surface` exists because that failure is invisible to Astro.
Add instance components and vocabulary cases as local `SpecimenGroup`s in the SAME address
space. Their `id`, not their component name, is the route prefix and anchor. Derive vocabulary
cases from the registry they demonstrate — for example every reference kind from the built
contract — rather than making the gallery a second hand-kept kind list. Keep the gallery frame,
prose, renderer imports and route placement local: those are the presentation choices that make
two instances' galleries different. The package owns the cases and the constraints, not a page
that would impose a visual system on its consumers.
ASSERT THE BUILT GALLERY, not only the source: every shared and local specimen renders; every
non-inline specimen has an emitted route; every iframe points to one of those routes; every new
package group fails until a renderer is wired; the gallery has an inbound link; and standalone
component documents are explicitly included in or excluded from search. Run the same emitted-CSS
style-gap checks over the result. The current executable example, including minimum and designed
themes, lives at:
https://jmchilton.github.io/foundry-lib/#/development/component-gallery
- Use site-kit's `ContentNote` for the invariant note frame and `TagChips` for tag markup. The frame
owns back navigation, metadata/badge/reference slots, tag placement, optional heading and
summary, and the article boundary. Pass a based `tagBase` only after the instance has a tag route;
without one, the same component renders controlled non-link chips rather than claiming a dead
destination. Domain furniture stays yours: a Mold's phases, a paper's provenance, or a package's
software facts enter through slots and never require the shared component to inspect frontmatter.
- Bind the collection-backed reader ONCE in site/src/lib/content-reader.ts with
`createContentReader({ collections: COLLECTIONS, contentPath, targetOf, aliases? })`. The package
composes kind-schema's collection matching with wiki-links' transforms, so note-file selection,
ids, link-map registration, remark links and raw-Markdown resolution all read the same table.
Do not walk the corpus or parse frontmatter in a caller to build aliases: return the instance's
alternate addresses from `aliases(meta, id, collection)`, and use the same optional `meta` in
`targetOf` when route targets need summary tooltips. Readers with neither requirement retain the
directory-only path.
Collection declaration order is contractual when two primary basenames collide; aliases fill
empty addresses and never overwrite primaries; explicit extra targets apply last. Only files
admitted by the collection table become note targets, so companions cannot acquire links to
routes that do not exist. Keep these rules in the package and its tests; the instance supplies
the vocabulary and the acceptance policy for unresolved links.
- Resolve wiki-links ([[Target]]) through that reader in BOTH the renderer and the validator. The
underlying wiki-links package owns the grammar, exact resolver and tree walk. Two rules it
settles are worth knowing before you are tempted to re-litigate them:
* RESOLUTION IS EXACT — no prefix fallback. Prefix matching makes empty or partial targets
resolve unpredictably and can turn a request for multiple notes into one arbitrary link. An
unresolved link renders BOLD — visible to a reader, obvious to an author, and never claiming
to lead somewhere.
* A BACKTICK MEANS THE SYNTAX, NOT A LINK. `[[Target]]` is how documentation names the token
and how a note names a slot it cannot link. The transform rewrites text nodes only, never
inlineCode/code/html. The validator also strips code spans before scanning, so a backticked
target is intentionally outside link validation. Put the rule in your authoring guide, not
just in the transform.
- Render explicit loose docs that live OUTSIDE any collection (the glossary, an ops log) through a
small local src/lib/render-vault-doc.ts composition: `contentReader.resolveMarkdown` supplies the
same link map as typed notes, then the instance's Markdown renderer and term-anchor transform
finish the document. Call it from a dedicated page — e.g. site/src/pages/glossary.astro reading
content/meta/glossary.md. Exclude each one BY NAME from the collection that otherwise claims its
directory; sharing `content/meta/` is a filing decision, not a reason for every file there to
bypass validation. `content/meta/*.md` is a validated `meta` collection, with only the named
loose docs skipped. The failure mode to avoid is a hand-written route registry standing in for a
collection: it omits real pages silently, and no schema or drift check ever reports the gap.
- EVERY SITE MODULE THAT READS A FILE OUTSIDE site/ ANCHORS ON `root` FROM `astro:config/server`,
never on a `../` count from its own `import.meta.url`. Give that one line its own module,
src/lib/repo-root.ts, because the import resolves only inside an Astro build; anything worth
testing outside one takes a directory rather than finds one.
`astro build` collapses pages, components and lib modules alike into a single chunk directory.
A hop count written against a source module can therefore resolve against a different depth in
the built site. The failure often looks like an empty list rather than a build error, so assert
expected item counts as well as rendered text. A test should walk site/src and fail any module
combining `import.meta.url` with a literal `..`; strip comments before scanning to avoid false
positives.
Exempt an upward search that walks one `dirname` at a time until it finds what it wants: that is
depth-tolerant by construction, and it is how you read an installed package's own version.
`path.resolve('../content')` is a different and lesser hazard — cwd is not a build-time property,
so it survives bundling and fails only if the build is invoked from outside site/. Keep it where
the module is loaded before `astro:config/server` would resolve, content.config.ts imports in
particular, and say in the file that this is why.
- Dev: `pnpm run dev` (astro dev). Build: `astro build --force`.
PART 3 — AUTHOR THE KINDS (THE CONTRACT IS INSTALLED; ONE KIND PER DIRECTORY)
Author the kinds one time and let both the site build and the standalone validator import them.
One encoding, two consumers — the schema is the sole authority on frontmatter shape. What a KIND IS
is not yours to define: `@galaxy-foundry/kind-schema` ships the contract, the assembly, the routing
and the doc reader. What you author is which kinds exist and what each requires. Author them as a
DIRECTORY PER KIND, not one module holding every kind. A kind is a unit of meaning: it has a
purpose, a required metadata set, and a worked example, and those three belong beside each other.
types/
context.ts the shared kind context — the base envelope + field primitives, plus the
one-time alias of the package's generic (below)
index.ts the barrel: the ONE enumeration of the kinds this instance defines
<kind>/schema.ts export const kind = defineKind({…}) (this kind's contract — NOT
annotated `: KindDefinition`; see "defend the inference" below)
<kind>/kind.md what the kind is FOR, and why each required field is required
<kind>/example.md ONE minimal valid note of this kind — frontmatter and a short body
Adding a kind is adding a directory plus one barrel line. Nothing else is edited, and a reader who
wants to know what a Mold is opens types/mold/ rather than scrolling a 400-line union.
- AUTHOR THE `meta` DESIGN-RECORD KIND FIRST, beside the domain kinds. Design records accumulate
whether or not you planned for them, and the ones sitting outside a collection are the ones that
go stale unseen. Route them like any other note. The shared shape is:
* `kind: 'meta'`, `layer: 'substrate'`, `shape: 'file'`, `companions: []`;
* flat files under `content/meta/*.md`, excluding `glossary.md` and any other explicit loose
document by name in the collection pattern;
* `title`, `record_kind: foundation | infrastructure`, and integer `order` in addition to the
honest lifecycle envelope your instance can support;
* one design-record index sorted by `(record_kind, order)`, with a test rejecting duplicate
order values within a shelf.
A design record is about the Foundry rather than the domain it knows. It is not a Mold reference
and is never cast merely because it lives under `content/`.
- SEED A SMALL ARCHITECTURE SET WITH ONE PURPOSE PER RECORD. The SPLIT is the rule: no catch-all
architecture document, and every record able to say what it does not own. The five names below
are the shared default, not a proven taxonomy — adopt them so a contributor can move between
Foundries without relearning where to look, and if your instance has a concern these five do not
cover, add a record and say so in the map. Do this after enough implementation exists to describe
truthfully; a greenfield scaffold may say explicitly that a package, caster, or fixture layer
does not exist, but must not document planned machinery as current. Use these paths and
questions:
* `content/meta/architecture.md` — the SHORT MAP: how do the major pieces and boundaries fit,
and which focused record should a reader open next? Aim for an orientation pass, not an
encyclopedia.
* `content/meta/code-architecture.md` — what implementation components exist, what does each
own, how do dependencies flow, and which cross-component contracts must hold?
* `content/meta/content-model.md` — what kinds of knowledge exist, and how do notes, metadata,
tags, references, links, and companions represent them?
* `content/meta/build-and-validation.md` — how does authored source become checked, generated,
cast (if implemented), rendered, and kept current?
* `content/meta/repository-layout.md` — where do authored, generated, vendored, external, and
temporary files live, and who owns each lifecycle?
The CONTENT is instance-owned: one Foundry may have a package graph and caster while a younger
one has one Astro application and an explicit casting gap. A record that keeps resisting its own
boundary is telling you something about the taxonomy, not only about the record.
The full map — every record's scope and exclusions, the change that obliges an edit to each, and
the reverse index from a change to the record that owns it — is the pattern's Design Records page
(`content/pattern/design-records.md`). Route a change through that index instead of opening every
record to find where a sentence goes.
- GIVE EACH ARCHITECTURE RECORD AN EXCLUSION RULE. `code-architecture.md` does not enumerate note
kinds or reproduce the file tree. `content-model.md` does not explain package dependencies.
`build-and-validation.md` owns flows and gates, not every module. `repository-layout.md` owns
placement, not conceptual rationale. `architecture.md` summarizes and links rather than
restating all four. Domain concepts keep their more specific homes — glossary, positioning,
guiding principles, Mold contract, casting semantics, corpus, or the domain's external-check
record. When a focused record grows hard to scan, split the concern it is mixing; do not repair
it by growing the map.
- BIND THE CONTRACT TO YOUR CONTEXT ONCE, in types/context.ts, and your kind files never mention
the generic again:
export type KindDefinition<T extends KindShape = KindShape> =
LibKindDefinition<KindContext, T>;
export const defineKind = kindDefiner<KindContext>();
`kindDefiner` is curried because `defineKind` has to stay generic in the kind's SHAPE while
already fixed to your CONTEXT, and TypeScript gives no way to bind one type parameter of a
generic function and leave the other free at the call site. This is the whole adoption cost.
- A kind directory exports a KIND DEFINITION, not a bare schema. The contract is `build` plus
`refine` over the FRONTMATTER, and `shape` plus `companions` over the LAYOUT — and nothing else:
defineKind({
kind: 'mold', title: 'Mold', layer: 'substrate' | 'instance', summary: '…',
shape: 'directory', // or 'file'. Required of every kind.
companions: [ // [] means none, and means it as an assertion
{ file: 'eval.md', requirement: 'recommended', disposition: 'foundry-only',
purpose: 'Fixture-independent property checks for this Mold.' },
],
additionalCompanions?: 'forbid' | 'allow', // 'forbid' is the default
build: (ctx) => z.object({ type: z.literal('mold'), ...ctx.base, … }).strict(),
refine?: (data, issues, ctx) => void,
})
Two properties of the installed type are worth understanding before you are tempted to work
around them. The shape is BOUND to carry `type` (`{ type: z.ZodTypeAny } & z.ZodRawShape`):
every kind you write would declare `type: z.literal(...)` anyway, but until the bound says so
that is a convention some directories happen to follow rather than something a consumer can
rely on — and it is what lets the kinds compose into a `z.discriminatedUnion`. And there is
deliberately NO third slot for assembling an entry from anything but its own frontmatter (see
the no-`transform` rule below). The package holds both of those with its own type-level tests,
so you do not owe a `.test-d.ts` for the bound. What you DO still owe is a runtime assertion per
kind: the shape declares `type`, and the literal parses to THAT kind's name rather than merely
being present. Nothing in the type system checks that a kind named `mold` says `mold`.
`kind` is the `type:` discriminator value — the sole note-kind discriminator, matching the
directory name. `layer` records whether the kind is inherited substrate or something this
domain added; it is what a cross-instance kind catalog groups by, so state it honestly.
It is `layer` and NOT `origin` so manifest metadata stays disjoint from frontmatter vocabulary;
a kind may legitimately use `origin` as one of its own domain fields.
- DECLARE THE LAYOUT AT THE KIND, NOT AT THE NOTE. `shape: 'directory'` means the note IS
`index.md` and everything else in that directory is either a companion or another kind's note.
Both fields are REQUIRED of every kind rather than opt-in: no kind legitimately does not know
its own shape, and optional declarations leave holes in the cross-instance catalog. A kind-level
declaration can say that a companion is required, recommended or forbidden and makes the same
fact available to validation and catalogs. One walk handing `checkCompanions` a directory
listing replaces per-kind allowlists and required-file constants; remove those older mechanisms
when adopting the declaration.
- `companions: []` MEANS NONE, and an absent key is not an option. "Not modelled" versus "none,
enforced" is a distinction invisible to a reader and unrenderable in a catalog without
misreporting one of the two, so the open-set half is said out loud instead:
`additionalCompanions: 'allow'` for a kind whose companion set is genuinely open — vendored
sidecars beside a research note, the acquisition files beside a book. It is NOT "unmodelled": a
kind may declare what it knows AND permit the rest. The default `'forbid'` is what makes a
misnamed `scenario.md` an error rather than a file the walker silently drops.
- A SIBLING THAT IS ITSELF A NOTE IS NEVER A COMPANION, and nothing can infer that from a
filename. `content/cli/<tool>/` holds an `index.md` that is a `cli-tool` and sibling `.md` files
that are each a `cli-command`, so `cli-tool` declares `companions: []` and still has a directory
full of markdown. Infer from the extension instead and every CLI command in the corpus reports
as a stray. `checkCompanions` therefore takes the note-or-not answer as INPUT rather than
deciding it; `kindOf(table, path)` from `./collections` is what answers, which is the second job
the one collection table of Part 2 is doing.
- A companion `file` is a LITERAL NAME in ONE directory — no globs, no path separators. A trailing
slash names a directory and is satisfied by that directory EXISTING; what is inside it is that
directory's business. Prefer a fixed layout over a wildcard escape hatch, and test directory-
shaped companions as well as files.
`disposition` is ONE axis — whether the file reaches a skill artifact. `foundry-only` never
leaves (eval, scenarios). `cast-input` means the caster READS it and it does not appear in the
output (rendering metadata or a placement manifest). `bundled` is copied in (a vendored schema).
The caster reads the referenced note's Kind layout and carries fixed `bundled` companions
automatically. Derive target exclusions from the same declarations rather than restating names
in target config. A file's presence in the directory does not make it eligible; the declaration
does.
- COMPANIONS DESCRIBE LAYOUT, NOT DEPENDENCIES. If a companion declaration starts growing
`used_at` / `load` / `mode`, it has turned into a second reference contract, and the answer is a
`references:` entry instead. Worth watching precisely because both sides can be described as
"a file this note needs" — the difference is that a companion says where a file SITS and a
reference says what a note DRAWS ON.
- FIXED COMPANION MEMBERSHIP BELONGS TO THE KIND. A referenced directory note carries exactly the
fixed companions whose disposition is `bundled`; `foundry-only` and `cast-input` cannot be
reclassified by note metadata. This is not inference from the directory listing: the Kind names
each eligible file, and validation separately checks whether the directory satisfies that
declaration. Missing required companions remain refs so the cast reports the missing source;
absent recommended or optional companions are skipped.
An open Kind (`additionalCompanions: 'allow'`) may let a note enumerate additional bundle members
in a per-note `companions:` field. Those are additions only. Reject absolute paths and `..`, and
recurse declared directories into one provenance entry per file. A closed Kind accepts no
per-note additions. Do not add a second `companions` permission to `reference_contract.yml`.
- A LAYOUT DECLARATION SAYS A FILE IS THERE, NEVER THAT IT SAYS ANYTHING. Whether `eval.md` exists
beside a Mold is the declaration's business, and `recommended` is what produces the warning.
Whether it contains a single property check is a rule about CONTENTS, which no layout vocabulary
should learn to express — keep those in the validator. Draw this line when you adopt the
declaration, or the checks it should have deleted stay, each now duplicating a warning the
declaration already emits.
- `build` returns the BARE OBJECT; `refine` carries the cross-field rules over the fields already
present. What that protects is the manifest generator's walk of `.shape` — and note that zod 4
changed who ENFORCES it. Under zod 3 a refined object became a ZodEffects: no `.shape`, and not
a legal discriminated-union member, so the separation was compulsory. Under zod 4 `.refine()`
leaves the type alone and hangs a check off it — a refined object is still a ZodObject, still
has `.shape`, still assembles into a union. The rule is now the CONTRACT's to hold, not the
library's, which is exactly when a rule quietly stops being obeyed. Keep it: `.transform()` is
the one operation that still wraps (as a `pipe`, whose `in` is the source type and which has no
`.shape`), so the hazard did not go away — it narrowed to the slot the next bullet says not to
add. A rule that has lost its enforcement mechanism and kept its reason is worth stating twice.
- DO NOT ADD A `transform` SLOT that assembles an entry from anything beyond its own frontmatter.
It makes the note incomplete in isolation, invites schema-time filesystem reads, makes the kind
manifest describe fewer fields than the runtime entry carries, and wraps the Zod object in a
value with no `.shape`.
The fix is to MATERIALIZE instead: a generator copies the assembled fields into each note's
frontmatter, a `--check` mode regenerates and compares in CI, and the note then validates in
place like every other one. Same trade kinds.generated.json already makes. Verify the generator
is idempotent (a second run writes zero files) and that `--check` is non-vacuous (edit one
source field, confirm it names exactly the affected notes and leaves the rest alone).
`KindDefinition` therefore exposes `build` + `refine`, preserving the discriminated union and
keeping the contract enforceable by the dependency.
- TYPE `refine`'S `data` AS THE KIND'S OWN INFERRED FRONTMATTER. Not `Record<string, unknown>`,
not `any`. This is not a style preference — an untyped `data` makes a validator rule SILENTLY
DEAD, and there is no runtime signal. Every refine rule is conditional: it fires only when
another field takes a particular value, so a rule that never fires is indistinguishable from a
rule with nothing to complain about. On a real corpus:
if (d.axis === "source-specifc" && !d.source) // typo — missing the 'i'
compiled clean, exited 0, and sat there looking enforced while bad notes passed validation.
Typed, the same line is `TS2367: This comparison appears to be unintentional … have no overlap`,
which enumerates the four legal values. Guard it with a `.test-d.ts` putting a typo'd literal
under `@ts-expect-error`, so widening the signature back fails the typecheck; verify it fails in
both directions before believing it.
If a discriminated union forces a cast somewhere, put it at the single DISPATCH site — where
looking the member up by `d.type` already makes it correct — never in the callee's signature,
which spreads the widening to every rule.
- `build` is a FACTORY taking the shared context, never a module-level constant, and the CONTEXT
ITSELF takes the registries — `buildKindContext({ tags, contract, licensePolicy })`, not a
module that loads YAML at import time. Both halves are needed and only the pair works. A context
that loads its own registries can only ever exercise a kind against the REAL ones, so a test
about tag validation has to add a tag to meta_tags.yml to run at all. Injected, you build a
context declaring a tag that appears in no file in the repo and assert both directions: the
schema ACCEPTS the synthetic tag, and REJECTS one the real registry declares. Check that pair is
non-vacuous by replacing the registry lookup with `t.length > 0` — the reject assertion must
fail, and pass again when restored. Neither assertion can hold while the injected registry is
being ignored. Passing the context also makes the substrate visible: whatever every kind draws
from is exactly what belongs in it.
- types/context.ts holds THE BASE ENVELOPE and the field primitives — the `tags` array (min(1)),
the registry-checked `tag`, `licenseId`, the license-coherence refinement, the typed `reference`
object, `wikiLink`. Each kind spreads `...ctx.base` and reaches into `ctx` for the rest. This is
the shared-substrate/instance-extension split applied to the frontmatter contract: context.ts is
what every Foundry's kinds have in common, the kind directories are what yours adds.
Port the envelope HONESTLY. A field you cannot populate truthfully (a `created` date you would
have to stamp with today across a corpus you did not author today, an `ai_generated` flag you do
not actually track) is worse than an absent one — it manufactures provenance. Leave it out and
let the kind catalog show the gap.
- types/index.ts is a STATIC barrel — `import { kind as mold } from './mold/schema.js'`, one line
per kind, collected into `export const KINDS = [mold, pattern, …] as const`. Do NOT glob at
runtime: the contract must load identically under tsc-to-dist, under vitest, and inside Astro,
and only static imports do all three. Keep it honest with a drift test (Part 4) rather than with
cleverness.
`as const` is not tidiness — it is load-bearing, and this is the one place a new instance is
most likely to lose the inference without any error to show for it. Assembly comes from the
package in two forms and you may need only one: `assemble(definition, ctx)` gives ONE kind's
schema, which is what a per-collection loader wants; `buildKindUnion(KINDS, ctx)` gives every
kind in one schema dispatching on `type`, for a validator walking a mixed corpus that cannot
know a note's kind before reading it. `buildKindUnion` is generic over the kind LIST, so it
recovers each member's type from the tuple you hand it. Hand it a widened
`readonly AnyKindDefinition<Ctx>[]` instead and validation still behaves identically while the
output type degrades to `any` — every field access compiling and yielding nothing.
- DEFEND THE INFERENCE. Splitting a schema across files introduces annotations that can widen an
inferred kind to its base shape or `any`, with the symptom appearing later in collection pages.
Three concrete rules:
* Do not annotate a kind `: KindDefinition` — that erases which fields it declares. Pass it
through a `defineKind` identity helper generic over its shape, so the shape is INFERRED.
* Do not type a shared field group as `ZodRawShape` (or a kind list as `KindDefinition[]`).
An index signature erases every field a kind spreads in. Use `typeof theActualConst`.
* Do not build the kinds map by MAPPING over the kind list. `.map` produces a homogeneous
array and collapses every kind to the widest common type; write the entries out by name,
and keep the LIST a tuple (`as const`) for the same reason.
Run the SITE's type check, not just the schema's — and know that A CLEAN TYPE CHECK DOES NOT
PROVE THE INFERENCE SURVIVED. A widening to `any` can satisfy every field access and produce no
typecheck error at all. A site that already casts
`entry.data as any` anywhere cannot catch it either. Zero errors is what both success and total
erasure look like.
So do not hold this with a typecheck, and do not hold it with a probe you delete. Diagnose with
the probe — access a field that does not exist and read the error: it must name the fields of
EVERY kind, the full union enumerated, and a real field must come back with its actual type
rather than `any` — then COMMIT the check in a `.test-d.ts` that asserts the same thing
permanently: the discriminator is assignable, narrowing on `type` reaches an arm-specific field,
a field NO kind declares fails under `@ts-expect-error`, and an arm-specific field fails
UNNARROWED. Re-verify by deliberately re-widening the kind list and confirming the file goes red.
- ONE COLLECTION TABLE, and everything that needs to know where a note lives reads it. A row is
`{ base, pattern, kind }` — typed `as const satisfies Record<string, CollectionRoute>` against
`@galaxy-foundry/kind-schema/collections`, with your schema hung off the same row:
export const COLLECTIONS = {
molds: { base: 'content/molds', pattern: ['**/index.md'], kind: 'mold', … },
'cli-tools': { base: 'content/cli', pattern: ['*/index.md'], kind: 'cli-tool', … },
'cli-commands': { base: 'content/cli', pattern: ['*/*.md', '!*/index.md'],
kind: 'cli-command', … },
} as const satisfies Record<string, CollectionRoute & { kind: NoteKind; schema: unknown }>;
Collection and kind are NOT required to be one-to-one; a collection is a LOCATION (its own browse
route), a kind is what a note IS. One directory may hold two kinds
(`cli/<tool>/index.md` is a cli-tool while `cli/<tool>/<cmd>.md` is a cli-command — note the
`!`-exclusion that keeps them disjoint), and multiple collections may resolve to one kind when
separate browse routes are useful.
Astro's site/src/content.config.ts then stays a thin, EXPLICIT consumer. Bind each table row to a
named `defineCollection({ loader: glob(...), schema: row.schema })` export and spell those names
out in `collections`. Do not map the table through `Object.fromEntries`: Astro preserves the
precise frontmatter type of each collection only when the schema-bearing entries remain explicit;
a generic loop widens them to their common shape and can turn the detail route's discriminated
union into `any` without a typecheck failure.
Every OTHER consumer — the validator's corpus walk, registry drift and wiki-link binding —
reads the same rows through content-reader or kind-schema's `matchesCollection` /
`collectionOf` / `kindOf`, rather than hand-rolling another selector.
Two things to get right. `base` is matched as a PLAIN PREFIX of the paths you pass in, so the
frame is yours to pick — repo-relative, project-relative, absolute — but it must be the SAME
frame as the paths and the same frame for EVERY row, or a collection silently claims nothing.
Pick the frame the table is naturally written in and give it a name (`contentPath(rel)`), so a
caller cannot half-convert. And assert your table PARTITIONS the corpus: `collectionOf` returns
the first match in table order, so two rows claiming one path is a table bug that order merely
hides — `collectionsClaiming` exists to write that test with.
- Encode cross-field rules as zod `.superRefine(...)` raising a specific, path-anchored issue,
never as prose in a README, and never a second time in the validator. Real examples: an on-demand
reference requires a `trigger`; a hypothesis-evidence reference requires a `verification`; a note
may not declare verbatim carry under an own-words-only license. If a rule matters, the schema
enforces it — and enforces it ONCE. A rule over the fields of one note belongs in the schema; only
a rule needing OTHER notes (wiki-link resolution, reference-target types) belongs in
the validator.
- Strip a trailing /index in the loader's generateId (a `stripIndex` helper) so entry ids and
wiki-link basenames stay clean (`msmb/chap1`, not `msmb/chap1/index`).
- Links are one-directional and backlinks are DERIVED — compute incoming references from the
same link fields and render them. Do not check that a link is reciprocated: it asks authors to
hand-maintain what the renderer already produces, and it complains to the wrong note, since a
hub collects one complaint per page citing it.
Emit a KIND MANIFEST — types/kinds.generated.json, committed, regenerated by a script with a
`--check` mode CI runs:
{ "instance": "<slug>", "version": 1,
"source": { "repo": "<owner>/<name>", "path": "<path to this file>" },
"kinds": [ { "kind": "mold", "title": "Mold", "layer": "substrate",
"summary": "…", "doc": "<kind.md body>", "example": "<example.md body>",
"shape": "directory", "locations": ["molds", "research/experiments"],
"companions": [ { "file": "eval.md", "requirement": "recommended",
"purpose": "…", "disposition": "foundry-only" }, … ],
"fields": [ { "name": "tags", "required": true, "type": "string[]" }, … ] } ] }
The FORMAT, the reader, and the zod-shape deriver come from @galaxy-foundry/kind-manifest — do not
re-encode them. The BRIDGE between the two comes from kind-schema:
buildKindManifest({
instance: '<slug>',
source: MANIFEST_SOURCE, // yours: repo + path
kinds: manifestKinds(KINDS, ctx, {
docs: loadKindDocs(KINDS, 'src/types'),
collections: COLLECTIONS, // `locations` are DERIVED from it, never hand-listed
examples: <yours>, // no loader ships for this one — see below
}),
});
The third argument is an OPTIONS OBJECT, not a positional docs record. Passing `loadKindDocs(…)`
bare yields a manifest with every `doc` missing because the object has no `docs` key.
`examples` is the one extra with no loader behind it: `loadKindDocs` is fixed to `kind.md`, so a
`kind name -> example.md body` record is yours to read — the same walk your kind-directory test
already does to prove each example parses. A catalog should treat an absent `example` as unknown,
not as an assertion that the kind has none.
`locations` is the collection bases routing to this kind, derived from the table rather than
declared, for the same reason the field list is derived from the zod shape that validates: a
hand-written location list is a second encoding of the routing table, and it drifts. Deriving also
gets the many-to-one case right for free — two collections resolving to one kind yield two
locations.
`manifestKinds` lives in kind-schema rather than kind-manifest because kind-manifest describes a
kind it is HANDED and must not learn what a `KindDefinition` is — its reader half has a consumer
that only ever reads manifests other Foundries produced and never defines a kind. kind-schema knows
both sides.
`loadKindDocs` (from `@galaxy-foundry/kind-schema/docs`) reads `<typesDir>/<kind>/kind.md` for every
kind in the list and TRIMS each body — trimmed because your `--check` gate byte-compares the
manifest, and a trailing newline that varies by editor would fail it on whitespace. It is driven by
the kind LIST rather than by a directory listing, so a kind with no `kind.md` is an error naming
itself and an unrelated directory under `types/` is not mistaken for a kind. It THROWS rather than
exiting: whether that means exit 1 is your command's call. This is a separate entry point because
it is the only part of kind-schema that touches a filesystem — everything else imports nothing from
`node:`, which is what lets your site pull `KindDefinition` into browser code without dragging `fs`
in behind it.
What is left for you to write is the composition: which kinds exist, and how your registries resolve
into the context they are built against. Keep your own `buildKindManifest({ instance, docs,
...registries })` signature over the package calls, so nothing downstream cares that the derivation
moved.
`fields` is DERIVED from the zod shape — walked, with optionality read off the schema — never
hand-written. A hand-written required-metadata list is a second encoding and it drifts the first
week. That derivation has just moved one repo over; the rule has not changed.
The manifest is what a cross-instance kind catalog renders, and what lets any number of Foundries be diffed
by machine instead of by eye. Because its consumer is ANOTHER REPOSITORY, `source` is split by who
actually knows each fact: YOU declare `repo` and `path`, so a consumer vendoring a copy does not
hard-code your identity on your behalf; WHOEVER TAKES A SNAPSHOT records the `revision`, via the
package's withRevision(). Do not put `revision` in your own generator — a committed artifact whose
gate regenerates it and string-compares can never carry the commit it was generated at, so
`--check` would fail on every commit.
The manifest is also what makes the no-`transform` rule above concrete: it lists the fields the
SCHEMA declares, so a kind assembled from anything other than its own frontmatter is described
here as fewer fields than it carries, and a reader of the cross-instance catalog is comparing a
partial row against a complete one without being told. Materialize; then the manifest is honest
for free.
PART 4 — STAND UP THE REGISTRIES, THE VALIDATOR, AND CI
Registries — TWO YAML files at the REPO ROOT, each holding only the half that is yours. The
formats, the loaders and the inherited vocabularies are installed (see "The shared substrate"):
- license-policy.yml — THERE IS NO SUCH FILE. The table ships in @galaxy-foundry/license-policy;
read it with bundledPolicy(). SPDX id → { name, policy: verbatim-ok | own-words-only,
license_file, copyleft, obligations }; unknown/missing → deny-by-default row with
`defect: true`. Apply the row to third-party pass-through or quoted expression;
`declaresVerbatimCarry(note.derived)` makes Foundry-authored own-words prose explicitly out of
scope. No row names a casting mode. What stays yours is license COHERENCE, which lives in your
validator: how the note's derived posture, declared license, optional license file and any
vendored source must agree.
- reference_contract.yml — holds `kinds` ONLY, and nothing else: the reference kinds YOUR Molds
author. used_at/load/modes/evidence come from @galaxy-foundry/reference-contract, and the loader
refuses a file that re-declares an inherited block (and refuses a shared table that declares
`kinds`) — the boundary is enforced in both directions, because it is the kind of split that
otherwise decays quietly. Compose the two halves in src/lib/reference-contract.ts. Your kinds
keep their own `href` into your spec docs; the inherited four link to the Foundry Pattern, which
is where they are specified. Once the instance adopts @galaxy-foundry/cast, a kind may also carry
the delegated `cast:` block Part 7 defines. Load it through `loadCastReferenceContract`; a
reader-only composition deliberately refuses that field because no installed capability acts on
it.
- meta_tags.yml — the controlled tag vocabulary, grouped into FACETS: `version: 1` plus
`facets: <facet>: { label, description, values: { <tag>: <gloss> } }` (`values` may be omitted
while a facet is still empty). Parse it with @galaxy-foundry/tag-registry — loadTagRegistry()
and the isValidTag/facetOf/facets accessors — never `yaml.load(...) as TagRegistryFile`. The
parser refuses a missing `facets` block, a facet without label/description, a tag with no gloss,
and a tag declared by more than one facet.
The three rules the format rests on, which the package enforces so you do not have to:
* A tag is valid when it is an exact key under some facet's `values`. Nothing else validates.
* EVERY facet is a CLOSED enum — no open/free-form/prefix-wildcard escape hatch, ever. A tag
with no gloss is a tag the browse surface cannot document and a reader cannot learn from, so
the registry is the complete, permanent catalog of what the corpus can carry. If an external
vocabulary changes upstream, enumerate the slice you actually use and re-run the drift check.
* Membership is DECLARED, not parsed off the `/` prefix — so the slash is a naming convention,
a bare key is an ordinary value (use one when a facet has exactly one flag-shaped member),
and browse pages group by the DECLARING facet, which makes an "other" bucket impossible
rather than merely empty.
What stays yours: the FACET VOCABULARY, and the schema rules over it — tags are cross-cutting
facets ONLY (note-kind is the `type:` discriminator and is never copied into `tags:`), and
`tags` min(1) so every note carries at least one facet. The package ships no vocabulary and
decides nothing about what a tag MEANS for a note. Seed corpus-first from the tags your first
notes actually use.
If a Foundry designates one note as the landing note for each value of a facet, declare that
relationship in frontmatter rather than infer it from the note's filename. The note schema checks
that the value belongs to the intended facet and that the note carries the tag it claims to lead.
The whole-corpus drift check then holds the relationship in both directions: every declared value
has exactly one landing note, and no value has two. Ordinary tag coverage is not enough — another
kind may carry the tag while its landing note is absent. Render the landing note distinctly on
the tag page, and derive a built-page assertion from the declared relationships so “landing note”
remains a reader-visible role rather than only metadata.
- LICENSES/ — vendored verbatim SPDX texts the policy's `license_file` rows point at, plus a
README. The table travels with the dependency; the texts a cast hashes are in YOUR tree.
Validator — vitest, one command, running the SAME schema the build uses:
- package.json scripts: "test": "vitest run" and alias "validate": "vitest run".
- site/tests/corpus-conformance.test.ts — walk the corpus THROUGH THE COLLECTION TABLE (Part 3),
parsing each note against its row's schema. The second consumer that makes "one schema, two
consumers" real. Walk it with the table's own matcher rather than a local `**/index.md` glob:
a walk that selects differently from the loader is a set of notes the site validates and the
validator does not, or the reverse, and neither surfaces as a failure.
- The same walk, one level up: CHECK THE LAYOUT, not only the frontmatter. For every directory
note, hand `checkCompanions` the listing you already have plus that note's kind, and act on the
three results it returns — `missingRequired` is an error, `unknown` is an error unless the kind
declared `additionalCompanions: 'allow'`, and `missingRecommended` is a WARNING. That middle
level is the one a test cannot carry: a test has pass and fail and nothing between, so
"expected, but its absence is not a defect" needs a validator with severities or it is not
asserted at all. Whichever you have, SAY SO — an instance that quietly demotes `recommended` to
`optional` because its runner has two outcomes has edited the spec to fit the tooling.
Two wiring notes. Tell it which sibling entries are NOTES; it will not guess, and the reason is
in Part 3. And drive the walk from the collection table, so the directories checked are exactly
the ones the loader routes — same single-walk rule as the conformance test above, same reason.
This check REPLACES per-kind allowlists; it does not sit beside them. Two carve-outs remain, and both are
about a file's CONTENTS rather than its presence: a rule that one companion's frontmatter uses a
fixed decision vocabulary, and the eval/scenario body checks. Those are not layout, and moving
them into a layout declaration is how that declaration starts growing a second reference
contract. And exclude dotfiles from the listing before you hand it over: editor and OS state is
not something a kind failed to declare, and it is the one exclusion the declaration cannot state
for itself.
- site/tests/frontmatter-schema.test.ts — a negative-fixture table: each broken frontmatter
asserts the SPECIFIC issue path+message it must raise, with a positive baseline per type. A
rule with no negative fixture is a rule you are only hoping is enforced.
- site/tests/kind-directories.test.ts — the types/ layout must hold, or the isolation it buys is
imaginary. Assert, per kind: the barrel enumerates EXACTLY the directories on disk (both
directions — a directory the barrel forgot is a kind that silently does not exist, a barrel line
with no directory is a stale import); `kind` equals the directory name; kind.md and example.md
both exist; and example.md's frontmatter PARSES AGAINST THAT KIND'S OWN SCHEMA. That last one is
what keeps the documentation executable — an example that stopped validating is a kind whose docs
now lie, and it is the cheapest positive baseline per kind you will ever get.
(A missing kind.md is already fatal to manifest generation, since `loadKindDocs` is driven by the
kind list and names the kind it cannot read. Keeping the assertion here anyway costs one line and
fails in the suite rather than in a generator, but example.md and the two directions of the barrel
are what only this test holds.)
Assert the walk found kinds at all, so a path change cannot turn the whole check vacuous.
Assert also that every kind SPREADS THE BASE ENVELOPE. A kind that spreads only a field
GROUP (the shared source-note fields, say) and skips `...ctx.base` passes every other check
and silently misses the next field added to the envelope.
- site/tests/*.test-d.ts — the TYPE-LEVEL guards from Part 3. Two, now that the shape bound is the
package's to hold: the assembled UNION (discriminator assignable, narrowing on `type` reaching an
arm-specific field, a field no kind declares failing, an arm-specific field failing unnarrowed),
and a typo'd literal in a `refine`. Each negative case under `@ts-expect-error`. These prove rules
that have no runtime behaviour to assert, so nothing else can hold them — and as Part 3 says, the
site typecheck provably does not. Three things to get right: vitest does NOT collect `.test-d.ts`,
so they run under `pnpm run typecheck` and are worth nothing if your tsconfig excludes them —
confirm the typecheck actually fails when you delete a directive; put the directive where
TypeScript reports the error, which for a kind definition is on the `build:` property rather than
above the `defineKind(` call; and verify non-vacuity by the widening itself, not by deleting a
line — re-widen the kind list to `AnyKindDefinition[]` and confirm the file goes red.
- site/tests/kind-manifest.test.ts — an INTEGRATION test, not a unit suite. The synthetic-shape
cases (does the deriver unwrap a defaulted array, is a refined string still `string`) live in
@galaxy-foundry/kind-manifest with the renderer they exercise; re-testing a dependency's unit
behaviour here would be the duplication the package removed, in a new place. What is only
testable HERE: your kinds against your registries, every kind's `type` field resolving to its
own literal, byte-stability across two builds so the `--check` gate means something, and a
round trip through the shared reader so a format break fails in this repo rather than in
another one. The `--check` gate alone is never enough — it regenerates with the same code and
string-compares, so a bug in the deriver produces a wrong manifest that `--check` then blesses
forever, and the manifest is the one artifact whose consumer is ANOTHER REPOSITORY.
- site/tests/registry-drift.test.ts — the registries and the corpus must agree BOTH ways. The
schema already rejects a note carrying an unregistered value; this asserts the other direction,
that nothing you AUTHORED is carried by zero notes — every tag under a facet's `values`, every
facet, every reference `kind` you trimmed to, every kind in the barrel. Assert the converse for
kinds too, against the collection table: every kind must have somewhere to PUT notes. A kind no
collection routes to is UNAUTHORABLE — its schema can never run, so "declared by zero notes" is
the only thing that would ever notice, and it reports the symptom rather than the cause.
Scope it to the per-instance vocabularies: the
INHERITED ones (the installed license-policy table, and the contract's used_at/load/modes/
evidence) are deliberately complete, so unused terms there are inheritance, not drift. The
exception is a group you NARROWED — narrowing makes it authored, so it rejoins this check.
Dead vocabulary is drift the schema's one-way membership check cannot report.
For any facet with designated landing notes, compare the facet's complete declared value set with
those notes' declared anchor fields and reject duplicates. Do not substitute the set of tags
carried anywhere in the corpus; membership and ownership answer different questions.
Assert here too that the raw meta_tags.yml declares no facet key the format does not define.
An unrecognized `open: true` is a silent no-op and reading the accessor cannot catch it. Read the
raw file for this assertion.
Keep this a WHOLE-CORPUS test; do NOT fold it into a validator that runs over an arbitrary
directory. "Registered but carried by nothing" is only meaningful against every note at once —
point the same check at a small fixture and almost the entire registry looks unused. Also assert
the walk found tags at all, so a frontmatter-reader change
cannot turn the whole check into a vacuous pass.
- Parse frontmatter with js-yaml (yaml.load), so an unquoted `access_date: 2026-07-13` coerces
to a Date exactly as Astro's loader does — the footgun surfaces in the validator, not in prod.
CI / deploy — two GitHub Actions workflows, working-directory: site:
- .github/workflows/ci.yml (on pull_request + push to main): actions/checkout@v6,
actions/setup-node@v6 (node-version 24), `corepack enable`, then
`pnpm install --frozen-lockfile` → `pnpm run typecheck` (astro check) → `pnpm test` (fails fast)
→ `pnpm run build`. Do NOT also pin the pnpm version in this file: corepack reads
`packageManager` from package.json, so the manifest is the one place it is stated, and a second
pin in CI is a copy that drifts silently the first time only one of them is bumped.
- .github/workflows/deploy.yml (on push to main): withastro/action@v6 (path: site) then
actions/deploy-pages@v5; permissions: pages: write, id-token: write.
PART 5 — BRING THE DOMAIN KNOWLEDGE AND ITS GROUNDING CORPUS
The real asset is what the Foundry knows. Bring the sources before building machinery around them.
- One directory per source: content/research/<papers|tutorials|books>/<id>/ holding a regenerable
faithful summary index.md and, by exception, an owned guidance.md listing the specific questions
to pull from it. Summary = regenerable cast; guidance = what you maintain.
- Grade a note by RECOVERABILITY, not coverage: good enough only if the target skill could be
rebuilt from the captured notes alone — no re-reading the source, no model memory. Hold the
specific facts recovery needs: numbers, thresholds, exact procedure, named decision criteria,
load-bearing quotes.
- Summary posture follows the LICENSE, not source type, and is recorded in the note's `derived:`
field: own-words-only → paraphrase (keep only short functional strings verbatim as facts —
error text, parameter names, thresholds, equations); verbatim-ok → short marked load-bearing
quotes allowed.
- Generate summaries in a CLEAN context (a fresh agent that sees only the source + its guidance.md,
never your analysis). Guidance directs attention, not conclusions ("does this source state X?
quote it" — never "confirm X"). Carry [summarizer-inferred]/access/re-check flags forward verbatim.
- Synced multi-chapter source: scripts/sync-book.sh reads content/research/books/<id>/manifest.tsv
(<num>TAB<url>), fetches into a gitignored raw/, pins SHA256SUMS. Redistribute NO source text —
only manifest + pin (provenance) and your derived summary. Book-invariant metadata (license/
attribution/derived) lives ONCE in a co-located book.yml and is COPIED INTO each chapter's
frontmatter by a generator with a `--check` mode, never merged in at load — this is the
no-`transform` rule of Part 3, and this is the exact kind that taught it. book.yml stays the only
place to edit; the generated block is overwritten; `--check` regenerates and compares in CI. The
chapter then validates from its own frontmatter like every other note, and its manifest row lists
the ten fields it carries rather than the six it declared.
PART 6 — IDENTIFY THE ACTIONS AND AUTHOR THE MOLDS
Decompose the domain into repeatable decision-and-handoff units — one Mold per action worth casting.
- Directory-note convention: content/molds/<slug>/index.md bears the frontmatter (typed reference
manifest + tags); siblings hold eval.md (fixture-independent property checks — the abstract
oracle, never packaged into a cast), scenarios.md (a fixture binding + its expected verdict),
and author-facing usage.md / refinement.md. Only index.md is validated.
- Declare each Mold's references[] as a typed manifest, each entry drawing kind / used_at / load /
mode / evidence from reference_contract.yml. Mark evidence honestly: hypothesis (speculative —
requires a verification), corpus-observed, or cast-validated.
- Get the boundary right: not one monolith, not a dust of fragments.
PART 7 — CAST ONE MOLD TO ONE TARGET
Adopt @galaxy-foundry/cast only after one real Mold and all of its references validate. The reader,
Kind catalog and reference view are a complete earlier vertical; do not install a caster merely to
make its configuration look anticipated.
- LOAD BOTH HALVES OF THE REFERENCE CONTRACT at the one composition point that already supplies
the site schema. `loadCastReferenceContract` delegates the `cast:` block to the cast parser while
returning the same reader-facing contract the site renders. A reference kind's block declares
strategy, never companion membership or target placement:
kinds:
environment:
label: Environment
description: A runnable fixture and its declared bundled companions.
ref_shape: wiki-link
cast:
resolve: note
default_mode: verbatim
`resolve` answers where the primary payload comes from (`note`, `package-export`, or
`payload-companion`). `note_types` widens a reference kind that intentionally addresses several
note types; do not rename the corpus to make the two vocabularies coincide. Omit `cast:` to make a
reference kind reader-visible but uncastable. Before cast is installed, load the contract with
`buildReferenceContract` instead; its refusal of an unknown `cast:` field prevents dead
configuration from looking implemented.
- BUILD ONE CORPUS INDEX and project every cast input from it: the slug map, parsed frontmatter by
path, and note targets. The map must apply the same aliases and collision precedence as prose and
frontmatter links, and it must be driven by the collection table so companions never become note
targets. Do not add a cast-only walk or slug policy. If the shared reader cannot expose a needed
projection, improve that seam upstream rather than make two indexes agree by convention.
- PASS THE KIND TABLE AS `kindLayouts`. Casting consumes only `shape`, `companions`, and
`additionalCompanions`, but it must consume the same declarations the validator and generated
manifest use. A missing layout is a cast error. Do not translate the table into target excludes,
note defaults, or a second list of companion filenames.
- DECLARE ONE TARGET in its own directory. The target owns bundle placement, document vocabulary,
per-reference-kind destinations, extensions and supported modes; the reference kind owns source
resolution. A minimal target is explicit about the runtime-facing document:
bundle_path: 'skills/{mold}'
document:
path: SKILL.md
noun: skill
kinds:
environment:
dst_dir: references/environments/
dst_extension: .md
modes: [verbatim]
Quote `{mold}` in YAML. Keep `_provenance.json` out of target vocabulary: it is the caster's
runtime-neutral account, not a format-specific document. A target may constrain a kind's modes
but cannot make an unsupported mode or uncastable kind valid.
- KEEP HOOKS DOMAIN-SHAPED. Supply the cast document's lede and sections, any implemented
non-verbatim renderers, extra bundle files and bundle checks. Register `payloadCompanion` or
`packageLoader` only when a reference kind selects those strategies. An absent implementation is
an error; never fall back to packaging a plausible but different source.
- CALL `castMold` with the loaded Mold, composed reference/cast contracts, reference kinds, corpus
index, Kind layouts, target and hooks. Treat `errors`, `drift` and `wrote` as values: aggregate
findings in the caller, set the process exit there, and publish only when the complete staged
bundle clears every check. A refused cast must leave the working tree untouched.
- HOLD THE FIRST BUNDLE AS AN EXECUTABLE CONTRACT. A write run produces the document, eligible
references, eligible companions and provenance. A subsequent `--check` reports no drift and
writes nothing. Exercise at least: an unresolved ref; a wrong note type; a missing Kind layout;
a missing required companion; an absent recommended companion; a `foundry-only` companion; a
bundled file and directory; a duplicate destination; stale owned output; and a license refusal.
Assert provenance hashes the actual source and destination bytes. Run the inert check in CI and
commit the bundle only when committed casts are part of the instance's distribution contract.
PART 8 — BUILD THE EXTERNAL CHECK
Decide what "correct" means in your domain and what can decide it. This is the sharpest, most
domain-specific part; the substrate deliberately leaves it open, so paths below are structural,
not a fixed tool.
- Prefer a deterministic decider where output is parseable (a validator/CLI that parses and
checks). Where correctness is not mechanically decidable, construct an empirical check the field
already trusts and make THAT the deliverable.
- Encode the check at the protocol altitude, not as a property buried on one Mold.
- If it must recognize bad work, ground it in a two-pole corpus: content/patterns/<slug>/index.md
with `pole: established-good` (methods + when they apply) AND `pole: cautionary-bad` (named
failure patterns + signatures + remedies). A checker that has only seen good examples cannot referee.
- Optionally keep standing scorecards for the doer skills (e.g. content/research/mold-eval/rubrics/):
a few near-orthogonal -ability axes graded as a VECTOR, never a rolled-up composite. Which axis
is low is the work-list.
PART 9 — COMPOSE, IF THE WORK IS SEQUENTIAL
Where a domain's task is an inherently multi-step journey, compose Molds into an ordered
end-to-end protocol and orchestrate them; a domain whose actions stand alone needs no such layer.
Keep composition optional and the individual Molds standalone.
INHERITED vs. SUPPLIED
Parts 2–4 are the substrate, and they now arrive in three grades rather than one:
INSTALLED The @galaxy-foundry capabilities this instance actually reaches. Not re-implemented,
copied or mirrored — dependencies selected by capability. The reader-and-contract
baseline is license policy, kind manifest/schema, the collection-backed content
reader, the four inherited reference vocabularies, tag-registry format, wiki-link
grammar and the reading shell/frame. Add cast when a real Mold is ready to compile;
add audit-citations only when scholarly-citation replay belongs to the acceptance
policy. Each keeps instance facts at one explicit composition point. The consuming
package.json and lockfile record the selected versions; foundry-lib, not this
checklist, owns compatible ranges.
MECHANICAL Re-stood-up rather than installed, because it is wiring rather than a format: the
site/ Astro app, the one-schema-two-consumers arrangement, the types/ directory
layout, the vitest validator, the ci.yml/deploy.yml workflows, the repo-root anchor
and the test that enforces it.
YOURS The halves the packages deliberately decline. Which kinds exist and what each
requires; your facet vocabulary; your reference `kinds`; your link aliases, routes
and unresolved-link policy; your license COHERENCE rules; your base envelope, ported
honestly; your site's identity and its palette; the domain furniture rendered
inside the shared note frame; and, once casting exists, reference strategies,
targets, hooks, corpus projections and the release threshold for cast findings.
Mechanical wiring is a package candidate when it exposes a stable, reusable contract. Duplication
is a prompt to investigate that contract, not sufficient evidence by itself. Reconcile genuine
differences before extraction so the package does not become the place where unresolved instance
decisions accumulate.
SURVEY BY THE QUESTION ANSWERED, NOT BY THE FILENAME. One responsibility may be split across
several modules or hidden behind different names. Ask which code answers a question — such as what
a note detail page looks like — and compare every answer. If an existing package already owns that
question, absorb local implementations at the instance's composition point. If no package owns it,
define the reusable contract before extracting code.
A PACKAGE SHOULD SHIP THE RULES IT CLAIMS TO OWN. If package documentation specifies behavior but
each consumer must implement it, the missing mechanism is an upstream package gap. Keep corpus
inputs local while moving stable interpretation and grouping behavior into the owning package.
PREFER STRUCTURAL FIXES TO CHECKS. One authoritative route or composition point removes whole
classes of drift; retain a small check that documents and protects that structure. For site URLs,
`shellBase` is the authoritative normalization rule. Each instance supplies one `lib/site-base.ts`
composition point beside `site-identity.ts`, and all pages and components import from it.
Part 1 and Parts 5–8 are the domain — but even the vocabulary starts from a shared spine: the
substrate terms in Part 1 are inherited from the pattern glossary and the stack terms from the
Astro-Stack Glossary. The terms beyond them, the corpus, the Molds, and the external check that
defines what "correct" means here are yours to supply. That last one is the part no other instance
can hand you.