zudo-led-lamp
GitHub repository

Type to search...

to open search from anywhere

Adopting the Evidence-Projection Pattern

How another project can build its own evidence-to-documentation projection on the provider-neutral core, and what the deferred package-extraction boundary means.

This repository publishes its component knowledge base by projecting structured evidence into documentation rather than re-typing it into prose. This page is the guide for doing the same thing in a different project, with different data.

It describes a pattern and a local code layout, not a product. There is nothing to install.

V1 is project-local code, not a package

The core described here lives at doc/component-docs/core/ in this repository. It is not published to a registry, not a framework feature, and not importable from anywhere else. Adopting it today means writing the same two-layer split in your own project — copying the shape, not depending on an artifact. Extraction into a real package is deliberately deferred; see The extraction boundaryfor what changes when it happens.

When this pattern pays off

It is worth the machinery when all of these hold:

  • You already hold structured, validated data that is the single source of truth, and a validator that can prove it.

  • That data carries uncertainty you must not flatten — conditions, units, provenance, verdicts, open questions, missing sources.

  • Some of it must never be published: internal review state, licensed extracts, integrity hashes, machine paths, agent-steering prompts.

  • Re-typing it into prose would create a second source of truth that silently drifts.

If your data is small, fully public, and carries no uncertainty, a hand-written page is cheaper and you should write one. The whole design below exists to make withholding and not-restating structural rather than a matter of author discipline.

The two layers

Everything rests on one split, and the dependency arrow only ever points one way.

LayerLocation hereKnows about
Corecomponent-docs/core/view model, publication policy, safe MDX, emit, pipeline
Adaptercomponent-docs/adapters/circuit/your file layout, your schema, your validator

The core never imports a provider module, never spawns a process, never mentions a concrete data format, and never touches a provider file path. A second source of the same kind of data is a sibling adapter directory and zero changes under core/.

Keeping that arrow one-way is what makes the core reusable at all. If you find yourself adding a provider mechanic — a path, a file format, a subprocess — to a core type, the design has slipped; push it back into the adapter.

Neutral about the provider, not about the domain

Be honest with yourself about which kind of reuse you are buying. This project's core is free of provider mechanics, but the view model it freezes is anelectronic-component one: identities carry mpn, manufacturer, lcsc,packageName and dnp; placements carry board and refdes; the field-key union includes record.lcsc. So a second supplier of component evidence really is just a new adapter — a different domain is not, because it needs the view model, the field keys and the renderers generalised first, which is a substantially larger job. Scope your adoption against that distinction before you start, not after.

If you are adopting into a different domain, the parts that transfer unchanged are the ones below that never mention a record: the three publication gates, the branded-type sanitiser, the preflight report, the emit/ownership rules, and the canary scan. Those are the expensive parts to get right, and they are the reason the pattern is worth copying even when the view model is not.

What the adapter must supply

type ComponentDataAdapter = {
  id: string;                                   // stable, appears in the preflight report
  contractVersion: number;                      // your schema's own frozen version
  supportedViewModelVersions: readonly number[];// refuses to run on a skew
  validate: () => Promise<ValidationOutcome>;   // a CALLBACK, not a command string
  selection: InstanceSelection;                 // committed instance allowlist
  matrix: PublicationMatrix;                    // committed per-field decisions
  project: (ctx) => Promise<PublicViewModel>;   // pure projection, runs after validate()
};

Two details are load-bearing:

  • project should be a pure function over already-parsed data. Here the file reads and the projection are separate, so every join rule and every publication rule is provable against a fixture corpus with no filesystem, no subprocess, and no write to the read-only evidence tree. Fold I/O into your projection and you lose that.

  • supportedViewModelVersions turns a core/adapter skew into a startup error instead of a subtly wrong page.

Validation: a callback, never a command string

Your data almost certainly already has a canonical validator. Run that one, and run it before any data is read.

Do not reimplement its rules in the generator's language. A weaker second validator that disagreed with the real one would be worse than no validator at all — it would pass things the real one rejects, in the surface that publishes.

The core declares only:

type ValidationRunner = () => Promise<ValidationOutcome>;

The adapter is where the concrete invocation lives. Here it runs the project's Python validator as an argument array — no shell, no string interpolation — from the repository root, and a nonzero exit aborts generation with the command, exit code and both streams attached. Offline-only: an online validation mode that mutates retained evidence has no business running inside a build.

Pin the interpreter or runtime version and let a missing or too-old one be reported as a validation failure, not a crash.

Publication: default-zero, three gates

A value publishes only if it clears all three. This is the heart of the pattern.

  1. Instance — its record ID (and for a source, its source ID) is on a committed allowlist.

  2. Field — its field key is PUBLISH in a committed matrix.

  3. Value — it survives sanitisation (text) or URL classification (links).

Default-zero has to be structural, not conventional:

  • The matrix is typed as Readonly<Record<FieldKey, FieldDecision>>. Adding a leaf to the view model without recording a decision does not compile. The default for anything new is "the build stops until a human decides".

  • The selection is an allowlist, so adding a record to your data does not silently add a public page. A listed instance the provider does not have is fatal.

  • The selection carries asserted counts (expect.records, expect.sources, …). A count changing means the corpus moved and the committed selection needs a human decision — which is exactly what should fail a build, not what should be quietly absorbed.

  • Repository visibility is irrelevant to all three gates. A public repo does not make a field publishable.

Keep link publication as a separate, narrower opt-in than record publication (linkableSourceIds here). Selecting a source to publish its title and locator is a different decision from republishing its outbound URL.

The URL and asset policy is deny-by-default

Allow only absolute http:/https: with a host and no embedded credentials. Deny — with a recorded reason — javascript:, data:, file:, protocol-relative URLs, bare hosts, absolute machine paths, Windows paths, embedded whitespace or control characters, and anything absurdly long.

Publish the original string, never a normalised one: normalisation changes a URL that your evidence may have locked a hash against.

For assets, the safe V1 default is to publish none — no PDFs and no arbitrary files from the data tree. Record that as an explicit decision (a field key with a DENY) rather than leaving it merely unimplemented, so the next person sees a decision instead of a gap. This project keeps that default for evidence files; its separate reviewed generators make only the narrow footprint-SVG and selected-WRL preview exceptions described below.

The preflight report

Generate a committed, deterministic report — no timestamps, every list sorted — and compare it byte-for-byte on every check run.

It should list selected instances, every field with its decision and how many values were emitted versus withheld, every URL considered with its decision and reason, and the published counts.

Two rules make it worth having:

  • Counts come from what the run actually did, not from what the matrix declares. A field marked PUBLISH that nothing reads then shows emitted: 0, and the drift is visible instead of theoretical.

  • Record a denied URL's reason and its owner, never its string. The report is a committed file; echoing a link you just refused to republish puts it in the repository anyway.

View model and stable IDs

Freeze a public view-model shape in the core and make unsafe values unrepresentable:

  • Every published string is a branded SafeText, every published URL a SafeUrl, obtainable only through the sanitiser. An unsanitised string is then a compile error, not a review finding.

  • Keep a value's JSON shape. A number stays a number so 42 renders as 42; units and conditions stay separate fields. If your data has a structured value, widen the union rather than flattening it to a string — and make an unexpected shape fatal rather than guessing.

  • No file paths, no provider identifiers, no raw data blobs in the model.

Sanitisation should be destructive-free: normalise (NFC) so bytes are machine-independent, then reject rather than strip control characters, bidi overrides and invisible formatting. Silent stripping lets a mutated string publish as a different-looking claim.

For identity:

  • Derive route slugs from the stable ID, not from a display name. Display names contain commas, slashes and case; IDs are what stay stable. Validate the slug against a strict pattern and a reserved-word list, and make a collision fatal.

  • Emit anchors as your provider's IDs verbatim, through a component rather than derived from heading text — then rewording a heading never breaks an external deep link. Assert anchor uniqueness per page, because an anchor becomes an HTML id and that is the document-scoped invariant.

  • Sort with a code-unit comparison, never a locale-aware one. Locale collation is ICU-dependent, which makes generated bytes machine-dependent and breaks any freshness check in CI.

Rendering safely

Evidence text is untrusted input to your renderer. Three layers, none optional:

  1. Build content only through a closed set of node builders that accept branded types. No raw-node escape hatch, so a caller cannot inject markup even deliberately.

  2. Serialize with the escaping extensions your format actually needs. For MDX specifically, the MDX extension is what escapes { — plain markdown does not care about braces, so without it every evidence string containing one is a live expression.

  3. Guard by re-reading the final output text and failing the build on anything your templates should never have produced.

Two consequences worth stating outright, because they are easy to erode later:

  • Evidence text never reaches an attribute. Attribute values are slugs and IDs only. If a renderer needs evidence inside a component, pass it as children.

  • No evidence in hydration data. Render server-side and keep generated data modules out of every client entry. Static output must carry the complete reference; scripting may enhance, never gate.

Generated-output ownership

Give the generator one exclusively owned directory and let it own that directory completely.

  • Check every write path with a segment-wise containment test, not a string prefix (/a/bc is not inside /a/b).

  • Never rm -rf the tree. Write this run's files, then remove only leftovers that live under the owned root, match the expected extension, and carry a generated marker.

  • A file under the owned root without the marker is a fatal error naming the path. Hand-authored content that wandered into a generated tree gets reported, never destroyed.

  • Refuse symlinks, both in the owned tree and at every step of a provider read — not just at the leaf.

  • Make emit idempotent: unchanged files are not rewritten, so a second run reports zero writes and mtimes stay stable.

Put hand-authored pages — including this one — outside that tree.

Tracked or ignored? Track it.

You must choose whether generated output is committed or git-ignored. This project commits it, and would again:

  • The diff is the review surface. A change in evidence shows up as a change in what the site says, in the pull request, where someone can see it.

  • CI can then prove freshness with git diff --exit-code, which catches a stale committed page and — with git add --intent-to-add first — a newly generated page that was never committed at all.

  • The site builds from a checkout without running your validator toolchain.

The cost is real: every evidence change carries generated churn, and contributors must run the generator before pushing. Ignoring the output avoids that churn but gives up the review surface and makes "what did this evidence change actually publish?" unanswerable from the diff. Choose deliberately, then wire CI to match — the two decisions are not independent.

Component reference assets are another generated surface

For this project, onboarding a component also selects a reader-facing document and package previews. Keep that selection explicit in the adapter's committed selection: choose every published source, exactly one inspected document source and its truthful kind (datasheet, specification, or drawing), and the reviewed footprint/model pair. A URL ending in .pdf is not sufficient evidence that it is a datasheet.

Commit the generated footprint SVGs and manifest plus the selected public WRL files, not only the generated MDX and preflight report. Regeneration must start from a canonical footprint that has a matching same-basename STEP and WRL model pair; the browser may use one format while the other remains an audit requirement. Make the preview checks part of the same pre-push gate as generation, so a record cannot gain a page with a missing or stale preview.

Build and watch wiring

Prefer package scripts over a build-tool plugin hook.

Script sequencing makes generation a process boundary: it has finished and exited before the site build starts. A plugin hook makes it an assumption about internal build-stage ordering that a future release of your build tool can re-order. Hooks also tend not to fire under every command — a preview or check command that skips generation while the build command runs it means the two disagree about who generates.

generate    validate -> project -> render -> emit
check       dry run; nonzero on drift
build       generate && <site build>          # generation precedes the content snapshot

In this repository, pnpm b4push also checks footprint previews and public models, then performs type, component, build, freshness, and artifact-scan checks. It is a local credential-free gate. The CI workflow runs its committed-output/link checks afterward; only its final Cloudflare preview deploy is credential-gated, so unavailable credentials never turn a skipped validation step into a passing build.

For watch mode:

  • Watch the source data, filtered to the extensions that matter.

  • Never watch the generator's own output tree. The generator's write must not wake the generator; let the site's content watcher pick it up.

  • Debounce, so a branch switch touching many files is one run.

  • Serialize, so triggers arriving mid-run cause exactly one follow-up run and never an overlapping second writer.

  • Keep watching after a failure. An editor save can leave a file briefly unparsable; that should not take the dev server down.

Export the scheduler separately from the filesystem watcher and unit-test it without touching the disk.

Search, history and progressive enhancement

These depend on your site framework, so treat the following as the questions to ask rather than as settings to copy.

Search. Find out what your framework actually indexes before you rely on it. This site's index truncates each page's body to 300 characters, which meant every exact identifier initially sat past the cut-off and nothing was findable. The fix was to move identifiers into the page description, which is stored whole. Measure it by replicating your framework's own scoring against the built index — not by assuming.

Accept that some granularity may be out of reach. Here, per-record identifiers are fully discoverable; individual fact and source IDs are not, because there are hundreds of them and a per-page description cannot hold them all. They stay reachable as deep-link anchors, as visible page text, and in the full-text LLM export. Name a limit like that as a tracked limitation — never quietly report it as satisfied.

History. Exclude generated pages from any document-history feature. Their git history shows generator churn, not evidence history, and the pages already carry their own provenance.

Progressive enhancement. The static page must be the complete reference. Scripting may filter, sort or collapse; it may never be required to read a value. A native <details> disclosure is a good fit for bulk reference data because it works with no script at all — but never put a claim behind one. Values, conditions, verdicts and sources stay in the open.

Testing: canaries and final artifacts

Proving the view model is clean is not the same as proving the built site is. Between them sit a compiler, a minifier, an indexer and whatever else your framework writes. Scan the bytes.

Harvest canaries from the raw source data, not through your adapter's types. Those types name only the fields the projection consumes, so denied fields are absent from them by design and a harvest through them would find nothing.

Four false-positive modes are worth designing against up front, because a scanner that trips on a clean build gets suppressed — and a suppressed canary masks the leak it exists to catch:

  1. A denied value that is a substring of a published one. Subtract before searching.

  2. Output escaping mutating published values. Normalise both sides.

  3. Degenerate values — a placeholder hash of repeated characters matches almost anything. Exclude single-character repeats and all-digit values from the canary set, and count binary artifacts without text-searching them.

  4. A denied value published by a channel you do not own. Split the scan into a tier for artifacts you write (full canary set — any hit is yours) and a tier for the rest of the site (only canaries no other content source publishes), re-derived each run rather than kept as a list of excused strings.

Then make it impossible to pass vacuously:

  • Fail on a hit, on too few canaries, and on too few artifacts. An empty projection satisfies every negative scan.

  • Run positive controls per surface, not against the union — a value present in the generated source but missing from the built HTML is exactly the regression worth catching, and a union check lets one surface cover for another.

  • Guard against a surface being empty. A positive control that skips when there is nothing to check proves nothing while looking green.

  • Never print a denied value into a failure message. Name the artifact and the field. That message lands in CI logs, which have a longer memory than the build.

Also assert the things that are true by construction until they are not: one built page per record and no page the projection did not produce, no evidence keys in hydration payloads, no credential-shaped strings in deploy artifacts.

Test the round trip to any agent-facing export in isolation. If your project can install documentation into a user-global directory, run that path with HOME pointed at a throwaway directory and verify afterwards that the real one was untouched. Never write to a user-global directory from a test, on a CI runner or on a laptop.

The extraction boundary

The core is the provider-neutral local API. Here it depends only on an adapter object and a generated-root path, and contains no reference to the project's data format, storage layout or validator runtime. That is what makes extraction possible — and it is still deliberately not done.

What would ship is therefore a component-documentation core, not a general evidence-projection framework: as noted above, the view model is domain-shaped. Naming that honestly at extraction matters more than it does today, because a package name sets an expectation that a local directory does not.

The reason is worth being precise about, because it is the one place a normally strict rule is suspended.

The view model carries a version (VIEW_MODEL_VERSION), and the rule is that an incompatible shape change bumps it. Through this project's build-out, the shape changed incompatibly more than once and the number stayed at 1. That was correct at the time: the core and its only adapter are compiled together from one repository, so a skew between them is a compile error long before any runtime check sees it, and nothing outside the repository has ever consumed the model. A bump would have implied a compatibility boundary that did not exist.

Extraction is exactly the moment that stops being true. Once the core ships separately, a consumer can hold an older core than the adapter it is paired with, the compiler no longer sees both halves, and the version becomes the only thing standing between them. From that point the rule applies literally.

So if you extract:

  • Bump the version at extraction, and treat every incompatible change after it as a bump. Do not carry the suspended rule across the boundary.

  • Expect to move the publication matrix's field-key union with the core; it is what makes an undeclared field fail to compile, and it is worthless to a consumer who cannot extend it. Decide how a consumer adds their own keys before you publish.

  • Keep the renderers' route shape configurable, or accept that every consumer publishes at the same paths.

Until then, the honest description of this code is: a working two-layer split in one repository, with the seam drawn where extraction would cut.

Where to look in this repository

ConcernPath
Full architecture contract and its reasoningdoc/component-docs/ARCHITECTURE.md
Provider-neutral coredoc/component-docs/core/
This project's adapterdoc/component-docs/adapters/circuit/
Committed publication reportdoc/component-docs/preflight.json
Generator tests, including the adversarial onesdoc/component-docs/tests/

The result those produce is live at Components — the catalog is the full index, and integration is the cross-record view that a per-record page cannot express.

Revision History

CreatedUpdated