Skip to content

Decision Log

cheyras edited this page Sep 23, 2026 · 6 revisions

Snapshot — a copy of the repository's DECISIONS.md as of 2026-09-07. The living version is the one in the repo; if these two disagree, the repo is right and this page is stale.

foilkit — Decision Log

The dated audit trail. An entry exists for any non-trivial decision, including decisions to not do something. Entries are never edited to look right in hindsight — a correction is appended instead.

Start here when something in the codebase does not make sense. The answer is usually already logged.

Format used throughout:

## YYYY-MM-DD — Short title
**Decided by:** <who>
**Decision:** <what was decided>
**Why:** <rationale>
**Implications:** <what changes or must be kept in mind>

2026-08-31 — The name is foilkit

Decided by: @cheyras

Decision: The project is named foilkit. Packages are scoped @foilkit/*; the repository is github.com/cheyras/foilkit.

Why: Namespace availability decided it. Re-verified against the live registries on 2026-08-30 and again on 2026-08-31:

Name GitHub npm unscoped npm @scope/core
holofoil taken (200) free free
holoforge taken (200) free free
foilkit free (404) free (404) free (404)
chromafoil free free free

holofoil was the preferred name and is unavailable on GitHub: the handle belongs to an empty personal account — 0 repositories, 0 stars, no bio — and squatter-release through GitHub's name-reclamation process is slow and unreliable. A name that is free on npm but taken on GitHub is not free; the repository is where contributors actually arrive.

mylar was ruled out regardless of availability — it is a registered trademark of DuPont Teijin Films.

Implications: Package names, the repository, the hosted subdomain (foilkit.deckpal.app) and every SPDX header follow from this. The npm namespace is not yet reserved (see the pending item below), which is the one part of this decision with a live clock on it.


2026-08-31 — No GitHub organisation; the repository lives under cheyras/

Decided by: @cheyras

Decision: foilkit is github.com/cheyras/foilkit, a repository under the author's personal account. No foilkit GitHub organisation is created.

Why: The original plan reserved a GitHub org as the trust anchor. It was dropped as premature. An organisation adds an administrative surface — seats, roles, org-level settings, an admin:org token scope the working credential does not currently carry — in exchange for a signal that matters only once there are multiple maintainers, which there are not. A single-author project under a personal account is the honest shape of the thing today.

Implications:

  • The GitHub namespace foilkit remains unclaimed. That is a deliberate accepted risk, not an oversight; if it is later taken by someone else the repository keeps working and only the vanity URL is lost.
  • Transferring cheyras/foilkit into an org later is a supported, one-click operation that preserves issues, pull requests, stars and redirects. Choosing the org now would not have been reversible in the same easy direction.
  • The planned GitHub App (contribution automation) is scoped to a single repository either way, so nothing downstream depends on the org existing.

2026-08-31 — MIT for the code, CC0-1.0 for the data

Decided by: @cheyras

Decision: Code under packages/ is MIT. The dataset — data/**, every *.canon.json, resolver tables, mask images — is dedicated to the public domain under CC0-1.0. Documentation is MIT. The split is declared machine-readably in REUSE.toml and, for files that can carry a comment, in per-file SPDX-License-Identifier headers.

Why: The stated preference was CC0 across the board, and the data is CC0 for exactly that reason — it is measurement of the physical world, and the project's thesis is that anyone can take the whole corpus and do anything with it.

The code is MIT anyway, for one specific reason: CC0 contains no patent grant and explicitly disclaims one. The FSF flags this, and a number of corporate legal teams carry a blanket block on CC0-licensed software. foilkit's entire thesis is that somebody drops it into a storefront or a collection app — MIT clears legal review essentially everywhere, CC0 sometimes does not. MIT's only cost over CC0 is a LICENSE file nobody reads: no real obligation, no attribution burden on adopters.

Documentation is MIT rather than CC-BY, deliberately. CC-BY's attribution requirement is a stronger obligation on a downstream reuser than MIT's notice-retention, and it would put a third license in anyone's SBOM for no gain. Two licenses is the number a legal reviewer wants to see.

Implications:

  • The boundary splits files, not packages. @foilkit/patterns will contain both: patterns/*.glsl is MIT, patterns/*.canon.json is CC0.
  • Every .ts/.js/.mjs/.glsl file must carry // SPDX-License-Identifier: MIT. This is new convention, not a port — the origin repository has zero SPDX headers anywhere.
  • JSON and PNG cannot carry a comment, so REUSE.toml's globs are the only machine-readable statement of their license. Removing a glob silently un-licenses a corpus.
  • Data contributions need an explicit CC0 dedication, not merely a DCO sign-off — see the contributor-terms entry below.

2026-08-31 — One monorepo

Decided by: @cheyras

Decision: All eight planned packages — core, three, webgl2, element, react, patterns, resolver, masks — live in a single repository as a pnpm workspace under packages/*.

Why: The deciding reason is the planned contribution App, which is scoped to one repository. A pattern contribution touching @foilkit/patterns alongside its canon file touching @foilkit/core would otherwise need multi-repository scope and two coordinated pull requests — for what is, to the contributor, one measurement.

Secondarily: the dataset and the renderer version together. A canon file is a full snapshot rather than a delta, so a split that let them drift would let them drift silently.

Implications: Splitting a package out later is easy. Merging repositories later is not. Release tooling has to handle per-package versioning inside one tag namespace.


2026-08-31 — Contributor terms: DCO plus an explicit CC0 dedication

Decided by: @cheyras

Decision: Code contributions require a DCO sign-off. Contributions touching data/ or any *.canon.json require a sign-off and an explicit CC0 dedication line in the pull-request body, carried today by a required checkbox in .github/PULL_REQUEST_TEMPLATE.md.

Why: A DCO sign-off certifies that you wrote the contribution or have the right to submit it under the project's license. It does not perform a public-domain dedication. CC0 is an affirmative act of abandonment; nothing in the DCO performs it. A contributor who signs off and adds a canon file has not dedicated it, and that hole in the dataset's public-domain claim is very hard to close retroactively — it means finding the person, years later, and asking.

Alternatives considered and rejected: a full CLA (heavyweight, kills drive-by contributions, wrong for a measurement corpus); relying on the DCO alone (leaves the hole); a dedication statement written by hand into every PR body, the SQLite/curl posture (works, but nothing prompts for it).

Implications: The planned GitHub App composes pull requests directly and will insert the dedication line into the PR body itself, so a contributor's approval of their own PR is their assent — nearly free, and it closes the gap at the moment of contribution. Until then the template is the fallback and is not optional. The template stays after the App ships, for pull requests opened by hand through the GitHub UI.


2026-08-31 — Standing ownership rule

Decided by: @cheyras

Decision: Ship nothing we do not own outright. Third-party material enters by source and reference, never by copy. Any original asset authored to fill a gap left by a rejected third-party one carries a notice file recording the investigation, the rejections and their reasons, and the authorship. The shape is specified in NOTICE-CONVENTIONS.md.

Why: A CC0 dedication is only worth what the dedicator's standing is worth. A traced trademark glyph or a vendored scan inside a public-domain corpus does not become public domain by being in it — it makes the whole dedication a claim nobody can rely on. The rule also has to survive a scanner and a legal reviewer, not just a good-faith reader.

Implications: No card artwork, scans, logos, game rips or traced marks in data/, in demos or in test fixtures. Reference imagery is cited plus a fetch procedure, not vendored. Every asset carries its notice, and the notice records what was rejected — a rejection trail is the part that proves the investigation happened.


2026-08-31 — Relicense record cites the mirror and the wiki, never a live SHA

Decided by: @cheyras

Decision: RELICENSE.md cites foilkit's own initial commit, the offline mirror deckpal-mirror-2026-08-31.git, and the DeckPal wiki page Foil-Branch-Log. It cites no live SHA on any DeckPal foil/* branch, and no tag was created to preserve one.

Why: Those branches are scheduled for deletion, which is what removes the third-party reference media from the origin repository. A tag pointing at a branch tip keeps the deleted objects reachable — it would defeat the deletion entirely. The mirror and the wiki page are the archive.

Implications: The authorship figure is the re-measured one: 465 commits, 76,291 words, 100% authored by cheyras <cheyras@gmail.com>, zero co-authors, measured 2026-08-31 against the mirror. The earlier working figure of 199 commits was counted against a single branch at an earlier date, was never re-verified, and should not be cited anywhere.


2026-08-31 — The wiki ships staged in the repository, because GitHub will not take it

Decided by: Claude Fable 5, on behalf of @cheyras

Decision: foilkit's seven wiki pages were written and committed to wiki-staging/ in this repository rather than pushed to the wiki. They move to the wiki, and wiki-staging/ is deleted, in a single follow-up commit once the maintainer has created the wiki's first page.

Why: GitHub does not accept a push to an uninitialized wiki. The feature is enabled on this repository, but until a person creates the first page through the web UI the wiki's git remote does not exist — cloning and pushing both fail with Repository not found. Verified 2026-08-31. There is no API, no CLI command and no token scope that initializes it; it is browser-only, and the working credential could not have done it regardless of scope.

The alternative was to hold the pages until the click happened, which risks them being written twice or not at all. Staging them costs one directory and one follow-up commit.

Implications:

  • Every …/wiki/… link in README.md, AGENTS.md and docs/README.md 404s until the click. They were written against the final URLs deliberately, so nothing needs rewriting afterward.
  • wiki-staging/README.md carries the exact steps for both halves.
  • Delete wiki-staging/ in the same commit that records the wiki going live. Two copies of a document with nothing keeping them in sync is worse than one.

2026-08-31 — Pending, not decided

Recorded here so they are not mistaken for settled:

  • npm namespace unreserved. foilkit, @foilkit/core and @foilkit/patterns were all free on the registry as of 2026-08-31, but the working machine is not authenticated to npm and npm login is an interactive browser flow. The npm organisation and the placeholder unscoped foilkit package (a 0.0.0 stub pointing at @foilkit/core, so that nobody ships a confusable package under the obvious bare name) both remain to be created. This is the item with a real clock on it.
  • Hosting. foilkit.deckpal.app has no Vercel project and no deployment. DNS needs nothing — deckpal.app already carries a wildcard ALIAS at Vercel, so the subdomain resolves today and returns DEPLOYMENT_NOT_FOUND. Standing it up is a Vercel project plus a domain assignment, both infrastructure writes requiring the maintainer's explicit approval.
  • The wiki is not initialized. Seven pages are written and staged in wiki-staging/; they cannot be pushed until the maintainer clicks "Create the first page". See the entry above.
  • The extraction itself. No library code, no dataset and no packages/ directory exist in this repository yet.

2026-08-31 — The rectifier: four corners → canonical 504 × 704

Decided by: Claude Fable 5 on behalf of @cheyras

Decision: tools/rectifier/ implements the missing half of the scan pipeline — a detected quad plus its image becomes a canonical raster and the 3 × 3 row-major homography that produced it — together with the pair-diff and three-way null / frame / full classifier that task 3b's reverse-holo delta measurement runs on. Zero dependencies: pure-maths DLT, hand-rolled bilinear warp, PNG over node:zlib, tests on node:test.

Why: 3b cannot start without it. DeckPal's dev/scan-harness is a detector — it returns corner lists and warps nothing — so every pair would otherwise be hand-aligned, which 3b's verification list forbids in as many words. Building it now also front-loads task 4's constants module and task 14's capture front end, both of which want exactly this code.

Implications:

  • No build step and no dev dependency. Node 22.18+/24 strips TypeScript natively, so the suite runs as node --test "tools/rectifier/*.test.ts". A tsx loader was considered and is not needed. No root package.json was added: the pnpm workspace is the extraction's decision to make, not this task's, and a README line documents the command in the meantime.
  • constants.ts is task 4b's constants module, early. The millimetre constants and the "3 mm corner is triangulated, not official, credible range 2.5–3.0 mm" provenance are carried from DeckPal's cardGeometry.ts. Nothing derived is typed in twice, and constants.test.ts reads the source back and fails on a numeric literal assigned to a derived export — 4b's verification item, satisfied by construction rather than by discipline.
  • Round-trip error is measured, and the synthetic number is not the real number. Synthetic smooth pattern through a keystoned quad: mean 0.0019/255, max 1/255. The same path with a real card scan: mean 2.28/255, max 54/255 (n = 1). Real cards are hard edges and 6 pt type; two bilinear resamples cost real error at every one of them. Anything downstream that keys on a single pixel's value budgets against the real figure.

2026-08-31 — WebP declined for the smoke test; PNG from the same path

Decided by: Claude Fable 5 on behalf of @cheyras

Decision: The real-scan smoke test fetches TCGdex's high.png rather than high.webp. The scans are downloaded by tools/rectifier/fetch-smoke-scans.ts into the gitignored reference-media/ tree and the test skips when they are absent.

Why: Decoding WebP with no dependency means writing a VP8 intra decoder — a real project, and one with nothing to do with homographies. TCGdex serves the same asset as .png from the same path, and png.ts already decodes PNG. Nothing in the rectifier is format-aware; it takes an RGBA buffer, so swapping in a WebP decoder later changes only the loader. Fetch-not-vendor is F2: the citation ships, the pixels do not.

Implications: smoke-scans.ts is the citation and ships; the images never do. A clone with no network passes its test run with the smoke suite skipped, which is honest, where a vendored card scan would be a licence problem.


2026-08-31 — The ported PNG codec was missing palette and Adam7

Decided by: Claude Fable 5 on behalf of @cheyras

Decision: tools/rectifier/png.ts extends the codec ported from DeckPal with colour type 3 (palette, bit depths 1/2/4/8, PLTE + tRNS) and Adam7 de-interlacing.

Why: DeckPal's codec had only ever read canvas.toDataURL output, so it supported neither. The first two real catalog scans this rectifier was pointed at — TCGdex high.png — are 8-bit palettised and Adam7-interlaced, and the original threw interlaced PNG unsupported on both.

Implications: Recorded because it is the smoke test earning its place on the first run: the synthetic suite would never have found this, and task 4a's framing census walks the same catalog rasters and would have hit it later and further from the cause. 16-bit still throws, deliberately.


2026-08-31 — Every classifier threshold is provisional, and says so

Decided by: Claude Fable 5 on behalf of @cheyras

Decision: CHANGED_PIXEL_DELTA (24), NULL_MAX_CHANGED_FRACTION (0.005), FRAME_MAX_INSIDE_CHANGED_FRACTION (0.02), EDGE_MARGIN_PX (4) and MAX_RESIDUAL_SHIFT_PX (4) ship as named constants whose comments state they are provisional and have never seen a photographed pair.

Why: F5 — a measurement carries its n, and this one's n is zero. The values separate the synthetic cases with room to spare and that is the entire claim being made for them. Publishing a bare number would have let a later reader mistake a placeholder for a finding.

Implications:

  • 3b's measurement is blocked on the physical pair capture and on nothing else. No bulk source ships variant-specific imagery, so both printings of a card have to be shot from the binder.
  • PLACEHOLDER_ART_WINDOW is explicitly not a measurement — the real windows are per-era in era-layouts.json. A result computed against the placeholder should say so, and classifyDelta refuses to separate frame from full at all when no window is supplied, returning the conservative class with trustworthy: false.
  • The alignment guard likewise reports rather than corrects: a pair whose two rectifications disagree is re-detected, never nudged.

2026-08-31 — Correction to "Pending, not decided" above

Decided by: Claude Fable 5 on behalf of @cheyras

Decision: The pending item reading "No library code, no dataset and no packages/ directory exist in this repository yet" is now partly out of date and is corrected here rather than edited in place.

Why: tools/rectifier/ is working code, checked in on this date. It is deliberately not under packages/: it predates the extraction, it is a tool rather than a published package, and constants.ts is the only part of it with a settled destination (@foilkit/core, per task 4b).

Implications: packages/ and the dataset remain empty and the extraction is still outstanding. The rest of that pending entry stands unchanged.


2026-09-01 — The extraction landed: five packages, one corpus, one receipt

Decided by: Claude Fable 5 on behalf of @cheyras

Decision: The foil runtime, authoring stack and corpus were extracted from DeckPal's foil/main (b803b43) into packages/{core,patterns,three,resolver,forge}, data/, docs/ and reference/. RECEIPT.md records the evidence that nothing changed in transit; its four measurements are 45/45 byte-identical renders, 177 passing tests, an identical resolver digest over 10,312 probes, and core compiling and running with three.js absent from node_modules.

Why: The origin branches are scheduled for deletion, and a move is only safely reversible-by-inspection if it arrives with proof. The receipt is a one-time artifact and says so: once read, the harness goes back to being a tool rather than a gate.

Implications: The pending item above — "packages/ and the dataset remain empty" — is now closed. Nothing is published: every package is 0.0.0 and no release has been cut.


2026-09-01 — Canonical space has exactly one definition, and the rectifier re-exports it

Decided by: Claude Fable 5 on behalf of @cheyras

Decision: packages/core/src/card-space.json (the datum) and canonical-space.ts (the derivation) are the single definition of canonical card space. tools/rectifier/constants.ts is now a re-export from @foilkit/core, and its contract test follows the definition rather than the shim.

Why: DeckPal carried two copies of the same arithmetic because its api tsconfig.json pinned rootDir and could not import its web sources; the rectifier, written here before the extraction, was a third. Its own header predicted this move. Three copies of an expression are three chances to disagree.

Implications: The rectifier's CARD_ASPECT is width/height and the shader's is height/width, so the shim maps them explicitly (CARD_ASPECT_WH / CARD_ASPECT_HW) rather than star-exporting — a silent swap there is a 1.95× error nobody would notice until a mask was cut wrong.

Still duplicated, and recorded as such: tools/rectifier/png.ts and packages/forge/src/png.ts are two hand-rolled PNG codecs over node:zlib. Both work, both are tested, and unifying them would touch the rectifier's own proofs. A follow-up, not a blocker.


2026-09-01 — CARD_ASPECT derives from millimetres, not from era-layouts.json

Decided by: Claude Fable 5 on behalf of @cheyras

Decision: @foilkit/core's CARD_ASPECT is CARD_ASPECT_HW, computed from the millimetre datum. It previously read era-layouts.json's cardAspect.

Why: era-layouts.json belongs to @foilkit/resolver, which is Pokémon-specific and optional by construction — someone rendering Magic cards wants the shader and not the resolver. Core reading it would have made the optional package mandatory.

Implications: Identical to every decimal the shader emits (1.39683, 0.0476), which the render receipt confirms at the byte level. The layout contract test still asserts that era-layouts.json agrees with the expression, so the two cannot drift apart silently.


2026-09-01 — Three DeckPal routes became configuration

Decided by: Claude Fable 5 on behalf of @cheyras

Decision: The glyph asset route, the artwork-citation URL a sidecar records, and the mask route a corpus report names are now configurable, each defaulting to a relative path a static host satisfies for free. DeckPal's @deckscout/db import became an injectable pool (registerAssetPool).

Why: A hardcoded host prefix in an extracted library means nothing anywhere else, and depending on a database this repository does not have would break @foilkit/forge's "node: builtins only" property — which is what lets it run with no install and no build step.

Implications: Nothing registered means nothing to look up, which is exactly the pre-existing unreachable-database path: a fallback, not a crash. The sidecar's artworkUrl stays a CITATION and never an asset; the pixels are never carried.


2026-09-01 — pg is not a dependency, and build-pattern-cards says why

Decided by: Claude Fable 5 on behalf of @cheyras

Decision: tools/build-pattern-cards.mts resolves pg at run time out of whichever project supplies the catalog (PG_HOST_PACKAGE), rather than foilkit depending on it.

Why: That tool INVERTS the resolver — it walks every printing in a catalog and records which ones each pattern governs. foilkit ships measurements of printings, not a list of them, and will not grow a database to keep one tool happy.

Implications: The tool still runs in both offline modes (--evidence-only, --fixture) with no catalog at all, which is what CI and a contributor without one use. Subtask 7 turns this into a build-time input question, because the hosted editor has no database at read time.


2026-09-01 — The pipeline job prompts were stripped too

Decided by: Claude Fable 5 on behalf of @cheyras

Decision: The auto-caption block embedded in 39 of the 44 reference/pipeline/jobs/*.json prompts was removed, alongside the transcript sections in all 44 notes.md and the inline quotes in the 4 gemini-spec.md files the content rules named.

Why: The rules named the notes and the four specs. They did not name the job files — but the job files carried considerably MORE transcript than the notes did, and the rule's reason (the creator's words are the creator's, exactly as the frames are) covers them exactly. Following the letter would have missed the larger half.

Implications: Each prompt now carries a note saying what was there and that fetch-reference.sh writes it back locally, from a source the operator fetched themselves. The prompt rubric — the part that is ours — is untouched, so the pipeline stays reproducible for anyone who has the media.


2026-09-01 — The parity harness renders both sides through ONE page

Decided by: Claude Fable 5 on behalf of @cheyras

Decision: tools/parity/ takes its shader, material, pattern and canon sources as URL parameters. The moving receipt was measured by pointing the same page at foilkit's packages/*/dist and at a plain tsc build of DeckPal's shader.ts + patterns.ts.

Why: The plan called for running DeckPal's harness in DeckPal and foilkit's in foilkit. Those are two different pages, and a byte difference between them would have been unattributable — different chrome, different backdrop, different card rect. Holding the page fixed makes the code the only variable, which is the claim the receipt is trying to support.

Implications: A deviation from the plan, and a strengthening one. It also leaves a general instrument rather than a one-off: the same harness re-runs the canon recheck under a future contract bump (one run per law, compared on mean absolute error), and it is the seed of subtask 6's stress demo.


2026-09-01 — Canon files frozen; tunedUnderContract deliberately not moved

Decided by: Claude Fable 5 on behalf of @cheyras

Decision: All 32 canon files now record every contract uniform and every declared param explicitly, at the value already in effect. 22 widened — the 21 saved on 2026-08-02, plus tinsel-ii, which lacked only uP4. Ten were already full. tunedUnderContract was not touched.

Why: A canon file claims to be a full snapshot. Twenty-one inherited the eight R4–R6 composite dials from GLOBAL_DEFAULTS at read time, which is a promise to track a code constant — harmless while the constant lived next door, not harmless once it lives in a separately versioned package.

Implications: Recording an inherited default is not a tuning decision, so those 21 are still tunedUnderContract: 1 and still in the retune queue. The zero-delta harness re-ran 45/45 byte-identical after the freeze, which is the only acceptable evidence for a "nothing changed visually" claim. Undeclared params and uScanBase are not written, for the same reason: writing them would assert decisions nobody made.


2026-09-01 — The lab shell did not come across

Decided by: Claude Fable 5 on behalf of @cheyras

Decision: FoilLab.tsx, CanonLab.tsx, MaskProvenance.tsx, ui.tsx and the api.ts HTTP client stayed in DeckPal. The coupling-free view code — CardViewer, useTilt, ViewTransform, MaskEditor, WindowEditor — came, under @foilkit/three/react.

Why: The extraction plan's package map named buildFoilMaterial, CardViewer and useTilt for @foilkit/three and nothing else. The lab shell is bound to DeckPal's router, its query client and its API prefix; the hosted contribution editor is its replacement, not its port.

Implications: This is a real loss if the origin branches are deleted before anyone decides otherwise — roughly 2,700 lines of working editor UI, including the canon lab's slider layout and the provenance panel. Flagged for the maintainer rather than settled here. The drawing surfaces subtask 8 would rebuild around are the part that came, which was the expensive half.


2026-09-01 — Extraction closeout: the evidence JSONs carry no third-party narration

Decided by: Claude Fable 5 on behalf of @cheyras

Decision: Five things, all of them consequences of one rule — data/** is CC0, and a CC0 dedication can only cover material this project has standing to dedicate.

1. The quote scrub. data/foil-card-assignments.json and data/foil-pattern-usage.json carried 162 YouTube-attributed sources[].quote strings, ~2,517 words, of which 127 were not marked as paraphrase, the longest verbatim run was 39 words, and one was labelled "cleaned auto-caption" in the file itself. Every one of the 162 has been rewritten as a short paraphrase in the maintainer's voice, prefixed Paraphrase (…), with the factual claim preserved — set names, pattern identifications, physical descriptions, demo cards — and the attribution normalised to video id (the url) plus chapter and timestamp. 162 of 162 are now marked; 0 unmarked. Where a timestamp was never recorded the text says so rather than inventing one. Both files also carry a $comment stating the policy, so the next contributor reads the rule before adding a row.

2. The caption write-back that did not exist. reference/README.md and all 39 pipeline/jobs/*.json prompts claimed fetch-reference.sh "writes the caption block back into this slot". No such capability existed. Rather than delete the claim, it was made true and honest: the docs now say the captions were removed from the repository because they are third-party narration, and fetch-reference.sh --captions (--captions-only to skip the video cut) slices each pattern's chapter range out of yt-dlp's auto-subtitles into reference-media/.captions/<slug>.txt, which the existing reference-media/ gitignore rule already covers. Each of the 39 jobs names that path in a new captionsFile field; pipeline/gemini_vision.py appends the text when the file is present and runs on the frames alone when it is not. RECEIPT.md carries a dated correction of the same claim.

3. The pkmn.gg citations stay. Ten sources[].url entries in data/foil-card-assignments.json (and the ten hostnames the derived index keeps) point at pkmn.gg. They are kept. pkmn.gg was ruled out as an ASSET source — we do not ship its images or its data — and that ruling says nothing about citing it. These are provenance records of where an observation came from: nominative use of the name, a URL, and our own restatement of what it showed. Deleting them would make the corpus less checkable while removing nothing that was ever copied.

4. The usage index no longer knows a host. tools/build-usage-index.mjs hard-coded http://127.0.0.1:3712/deckscout/api for its optional coverage report — a DeckPal address, in a repository with no catalog. The URL is now an opt-in --api <base-url> flag or FOILKIT_CATALOG_API env var with no default baked in; with neither, the report is skipped cleanly and the output file is unaffected, as it always was. Both index builders also had stale extraction paths (../../research/* → apps/web/src/foil/*) and could not run at all; they now read data/ and write packages/resolver/src/. Regenerating with the fixed paths reproduces both committed indexes byte for byte, which is the evidence that the path fix is right and that no quote text ever reached them. reference/fetch-reference.sh additionally had a latent set -u failure — local id="$1" out="…$id…" aborts under bash 5.2 — fixed in the same pass.

5. Verification. The resolver digest probe re-ran over the same 10,312 inputs: 8541aa1b389d5a8b04508aa237189dce196ef470f358dc913c8c9584beafc367, identical to the receipt, and the two output files diff clean line for line — the scrub moved no guess. 177/177 tests pass, reuse lint clean.

Why: Third-party narration in a CC0 file is the one defect that makes the dedication itself false, and the dataset is the half of this project that other people are meant to be able to take. The evidentiary value of a citation is the claim plus the pointer, not the wording; paraphrase keeps all of the first and loses none of the second.

Implications: These were the last open items before DeckPal's foil/* branches are deleted. Four verification footnotes belong with them, because each one looks like a discrepancy until it is explained:

  • The contract stamp is 2, not 4. The extraction spec says canon files stamp contract 4; revision 4b renumbered the contract sequence, and 2 is the value that survived. The spec's "4" is superseded, not violated.
  • foil/tooling-research was a path SUBSET of foil/main. Nothing was lost by extracting from foil/main alone; there was no second corpus to merge.
  • The literal string "Transcript excerpt" appears in two mid-history commit READMEs. It is a reference to the section that was stripped, not the section's content. git grep -ci transcript at HEAD hits four files, all of them meta.
  • MANIFEST.json's frame check is width-only. Heights were verified by hand this round: 344/344 frames at 270 px, matching the recorded 480 × 270.

Flagged, not fixed — for the maintainer. The scan that closed item 1 also looked at the 340 non-YouTube quotes in the same two files, and they have the same shape of exposure: the longest is 83 words from insights.collexy.com, with 177 Bulbapedia quotes (longest 56 words), 14 PokeBeach (58) and 9 BleedingCool (34). Bulbapedia is CC-BY-SA and Collexy reserves its rights; neither is CC0-dedicatable by us. This closeout was scoped to the YouTube narration and deliberately did not widen itself. It is the obvious next scrub, and it is recorded here so that deleting the origin branches does not delete the knowledge that it is outstanding.


2026-09-01 — The written sources are paraphrase too: the 340 the closeout flagged

Decided by: Claude Fable 5 on behalf of @cheyras

Decision: The follow-up scrub named in the previous entry has been done. The 340 non-YouTube sources[].quote strings in data/foil-card-assignments.json and data/foil-pattern-usage.json — 9,632 words, none of them marked — are now 340 paraphrases in the maintainer's voice, 6,984 words, all 340 marked. Combined with the 162 video citations the closeout rewrote, all 502 quote strings in both files are marked paraphrase and 0 are unmarked, from any source.

Source Quotes Longest original
Bulbapedia (CC-BY-SA 2.5) 177 56 w
Collexy (all rights reserved) 124 83 w
PokeBeach 14 58 w
pkmn.gg 10 30 w
Bleeding Cool 9 34 w
Sleeve No Card Behind 6 22 w
Total 340 83 w

Attribution was normalised the way the video citations were, to the same Paraphrase (…) — form: publication name plus the page title the URL already names, with the url field unchanged beside it — Paraphrase (Bulbapedia, 'Holofoil') — …, Paraphrase (Collexy, 'XY era holofoil overview') — …, Paraphrase (pkmn.gg, 'Silver Tempest Trainer Gallery' set list) — …. Nothing was invented to fill a field: none of these URLs carries a section anchor, so none of the citations claims one, exactly as the video citations say "timestamp not recorded" rather than guessing a timestamp. Both files' $comment policy note now covers every quote rather than only the video ones, and says why.

Why: The closeout's rule has not changed — data/** is CC0, and a CC0 dedication can only cover material this project has standing to dedicate. What changed is the recognition that the written sources are the worse half of the problem, not the lesser one:

  • Bulbapedia is CC-BY-SA 2.5. That is not merely "not ours to dedicate", it is a direct licence conflict inside a CC0-declared file: CC-BY-SA is copyleft, it requires attribution and share-alike on any reuse, and CC0 is a waiver of exactly those conditions. 177 CC-BY-SA strings sitting under a CC0 notice made the notice false about a third of the file's citations, and would have propagated a share-alike obligation to every downstream user who took the dataset at its word. A paraphrase of a fact carries no such obligation: CC-BY-SA covers the expression, and the facts about which foil pattern a set used are not copyrightable in the first place.
  • Collexy, PokeBeach, Bleeding Cool and Sleeve No Card Behind reserve their rights outright. The 83-word Collexy passage was the single largest block of third-party prose anywhere in the repository, larger than any video quote the closeout removed.
  • pkmn.gg stays cited, for the reason given last time. Its ten entries were already the project's own restatements of set-list counts; they are now marked and attributed like the rest. Citing a source is not copying it.

Implications and verification.

  • The guesses did not move. The resolver digest probe re-ran over the same 10,312 inputs and returned 8541aa1b389d5a8b04508aa237189dce196ef470f358dc913c8c9584beafc367 — identical to RECEIPT.md and to both receipt files, with the same tier split (4,160 facet, 2,664 set, 2,520 series, 880 card, 88 heuristic) and RESOLVER_VERSION 5. Citation text is evidence for a row, never an input to it, and the digest is what proves that rather than asserting it.
  • Both derived indexes regenerate byte-identically. tools/build-assignments-index.mjs (125 rows, 21 facets) and tools/build-usage-index.mjs (122 rows) produce no diff at all — not even a citation-text change, because neither index carries quote text, only hostnames.
  • 177/177 tests pass; reuse lint is clean (575/575 files carry copyright and licence information; CC0-1.0 and MIT, no bad or deprecated licences).
  • Longest remaining verbatim-looking run: 19 words, and it is a list of Pokémon names — "Ho-Oh, Lugia, Pikachu, Wobbuffet, Hoothoot, Noctowl, Feraligatr, Meganium, Typhlosion, Latias, Latios, Cleffa, Smoochum, Shuckle, Raikou, Entei, Suicune, Porygon" — which is the enumerated fact itself and is not expression anyone can own. The next two are set names in a list (11 and 10 words, both "Wizards Black Star Promos, Southern Islands and Best of Game"). The longest run containing any connective prose at all is 8 words — "Raichu came in the Supreme Victors Value Pack", five of them proper nouns. Mean shared run across all 340 is 4.4 words. The longest paraphrase in the file is 46 words, against an 83-word longest original.

This closes the item the previous entry flagged. Nothing about the source-quote policy is now outstanding in either evidence file.


2026-09-01 — The stage is two packages: policy without a renderer, and a binding with one

Decided by: Claude Fable 5 on behalf of @cheyras

Decision: "One renderer, any number of cards" ships as @foilkit/stage (the budget ladder, the six tilt sources, the frame schedule, the scissor arithmetic, the texture sizing — importing nothing) plus FoilStage inside @foilkit/three (one canvas, one WebGLRenderer, a material cache keyed by patternId, a texture cache keyed by URL, one rAF loop, one IntersectionObserver, one ResizeObserver).

Why: folding all of it into @foilkit/three was the obvious alternative and was refused for a concrete reason, not an aesthetic one. A packages/stage that depended on @foilkit/three would have been imported back by three/react/CardViewer, and TypeScript project references forbid the cycle; breaking the cycle by moving CardViewer into the new package would have changed its import path, which is a public shape this work promised to keep. Splitting policy from binding has no cycle in either direction, and it buys something real: the ladder and the sources are arithmetic over rectangles and frame times, so they are tested by node --test with no GPU (40 new tests), and a future webgl2 or element adapter inherits the architecture instead of reimplementing it. tools/check-independence.mjs now proves stage imports no renderer, exactly as it does of core.

Implications:

  • CardViewer keeps every prop it had and becomes a thin host registering one element, in blit mode so its overlays and pan/zoom wrapper are untouched. useTilt keeps its public shape and delegates the mapping to the stage's sources.
  • options.renderer is honoured, never restyled and never disposed: a host already running a three.js scene must not be forced into a second context.
  • renderScale is a no-op in underlay. A card's pixels there ARE the page's pixels and cannot be scaled up after the fact. It is live in blit, where a card is rendered small and stretched on the way out. Rung 1 therefore has one step fewer in underlay, and the ladder moves on to rung 2 rather than pretending otherwise.
  • Frozen (rung 3) and stopped (rung 4) cards are still DRAWN in underlay. Freezing means the image stops changing, which is what the rung is for; it saves the per-card CPU work and not the fragment shading. In blit a frozen card skips its render and its blit entirely, so it saves both.

2026-09-01 — The ladder reads work time, and preserveDrawingBuffer was refused

Decided by: Claude Fable 5 on behalf of @cheyras

Decision: The budget ladder's input is the stage's own work time — how long its frame callback took — compared against the budget of the cadence currently being run when deciding to drop, and against the TARGET cadence when deciding to climb. blit reads the shared drawing buffer with drawImage inside the same frame, and the renderer is created without preserveDrawingBuffer.

Why: the obvious signal, the wall-clock gap between frames, is self-defeating: rung 2 lengthens that gap deliberately, so a ladder reading its own cadence as evidence of load can drop but can never climb back. Judging drops and climbs against different budgets is what lets a stage sit correctly at 30fps without either oscillating or getting stuck there.

preserveDrawingBuffer: true was considered for a different design of rungs 3–4 — clear only the dirty card rects and let frozen cards' pixels persist, which would have saved fragment shading in underlay too. Refused: it costs a driver-dependent full-frame copy every frame, and it breaks the moment the page scrolls, because a persisted rect is in the wrong place as soon as its element moves. Reading the buffer inside the same task needs no such flag.

Implications: the ladder can be exercised deterministically by adding measurable work rather than by finding a slow machine — stage.syntheticLoadMs busy-waits inside the measured region, the demo exposes it as a slider, and the acceptance test uses it. That is the only honest way to assert "engages and recovers" in CI, since the ladder reads work time and has no idea where the work came from. Measured on this run: forced 26ms of work per frame took the ladder from step 0 to step 7 (rung 2, "30 fps") and it returned to step 0 within ~15s of the load being removed.

2026-09-01 — The stress demo commits no card imagery, and its faces are arithmetic

Decided by: Claude Fable 5 on behalf of @cheyras

Decision: apps/demo draws its card faces in a canvas from a seeded generator. No scan, no fetch of one, no fixture. What is real in it is the shipped recipes, their canon snapshots read from data/foil-canon, the shipped composite and the shipped stage.

Why: F2 (source and reference, never copy) is written to cover data/, a demo and a test fixture in the same breath, and there is nothing to trade away here: the texture budget is a function of SIZE and COUNT, and the generated faces are the same size and count real scans would be. A blank base also exercises the classic composite path (uScanBase: 0), which is the one the canon files were tuned under.

Implications: the demo is runnable by anyone who clones the repository, with no credentials, no image proxy and no network beyond the local static server — which is also what makes it viable as a CI gate. The acceptance job runs it in headless Chromium under SwiftShader as a separate workflow job, so a contributor's unit-test failure is not queued behind a browser download.

2026-09-01 — Vector masks rasterise at the size the stage chose, and the common tier uploads nothing

Decided by: Claude Fable 5 on behalf of @cheyras

Decision: The stage takes a maskVector — a rasteriser it calls with a width and height IT picked from the card's on-screen box — rather than a stored raster. Masks get half the face budget and a 512px ceiling (maskTextureWidth). Cards sharing a maskVectorId share one rasterisation per size. Where there is neither a hand mask nor a vector one, the layout-rect tier renders with no texture at all.

Why: uMaskTex is low-frequency alpha — the feather is 0.008 UV, several pixels wide at any plausible size — so a mask matched to face resolution buys detail the shader immediately blurs away. And a mask stored as geometry has no size to be wrong: the stage measures the box, picks a width, and asks for it, so "which raster size do I store" is a question that never gets asked. The signature is deliberately the one @foilkit/forge's rasterizeTemplate(tpl, w, h, opts) already has, so the authoring stack plugs in unchanged, and rasterizeSvgMask(d) covers the case where a host has only a path string — Path2D speaks SVG path syntax natively, arcs included.

The stage does NOT depend on @foilkit/forge to do this. Forge reaches for node:fs in several modules, and a browser-side stage importing it would drag the authoring stack into every consumer's bundle. Passing the rasteriser in is one function and no dependency.

Implications: measured in the demo — 42 mounted cards, one third of them vector-masked, produce one raster. The other two thirds upload nothing, which is the reason three hundred cards hold six textures rather than three hundred and six. Changing a template's geometry requires changing its maskVectorId; the cache key is the id and the size, not the function identity.

2026-09-01 — The dead band probes, because a one-way ladder is not a ladder

Decided by: Claude Fable 5 on behalf of @cheyras

Decision: After probeAfterMs (default 5s) sitting in the dead band — under budget, but without the clear headroom a fast climb needs — the ladder steps up ONE rung to see whether that rung holds. A probe undone by a drop within probeGraceMs counts as refused and the probe interval doubles, to a 60s ceiling; clear headroom resets it.

Why: CI found this, on the same run as the pixel-ratio bug, and it is the more interesting of the two. The GitHub runner's resting frame work landed inside the dead band. That made it stable at step 0 — correctly, nothing was over budget — but it also made it stable at step 5 after a transient, because climbing required headroom the machine never had. The run recovered 8 → 5 and stopped, and a page in that state is quietly worse until it reloads. "It climbs back up as headroom returns" cannot be true if the ladder is a one-way door: a stall becomes indistinguishable from a correct answer.

The dead band's width is what makes this bite. It is 0.6 to 1.0 of the target budget — 10ms to 16.7ms at 60fps — which is a large and entirely ordinary place for a real machine to live.

Implications: a transient can no longer permanently degrade quality; the worst case is a machine that oscillates between two adjacent rungs, and the backoff makes that rare and then rarer. Two tests pin it: a transient followed by 40s of dead-band work must return to step 0, and a rung-dependent workload where the step above genuinely does not hold must produce fewer than 30 rung changes over 320 simulated seconds — where a fixed 500ms probe would produce about twelve hundred.

2026-09-01 — Boxes handed to a renderer are in CSS pixels

Decided by: Claude Fable 5 on behalf of @cheyras

Decision: cardGlBox returns CSS pixels, and everything the stage hands three.js is in that unit. The only device-pixel arithmetic left is the blit's drawImage source rect, which reads the drawing buffer directly.

Why: three multiplies every setViewport/setScissor by its own _pixelRatio on the way to GL. The stage was converting to device pixels first, so the ratio went on twice. That is the identity at ratio 1 and invisible; at 0.5 it draws every card into the wrong quarter of the screen. It survived local testing and four green checks in the same CI run for one reason: this machine's ladder rests at step 0, so every screenshot was taken at ratio 1. The runner's rests lower, and both modes screenshotted flat background — one distinct colour, luma 14 to 14 — while contexts, programs, textures and draw calls all still read correct. The cards were being drawn perfectly, somewhere else.

Implications: the acceptance now screenshots each mode a second time with the ladder PINNED to a reduced-resolution step. A bug invisible at full quality is not caught by a test that only ever runs at full quality, and waiting for a slow machine to reveal it is not a test — it is luck, and this time the luck belonged to CI rather than to a user.

2026-09-01 — npm publishes will use @cheyras/foilkit-*; the repo does not rename

Decided by: @cheyras

Decision: the workspace keeps @foilkit/core, @foilkit/patterns and the rest. When these are published it will be under @cheyras/foilkit-*, because there is no npm organisation behind this project and one person's scope is a more honest home for it than an org created only to hold a name. Nothing in the repository is renamed now.

Why: the scope a package is published under and the specifier the source imports are different questions, and conflating them costs a churn commit across every file for zero behaviour. Subtask 1 reserved foilkit on npm; the org was never created, so @foilkit/* is not actually held.

Implications: README.md states the publish name so a reader is not surprised. The rename happens once, at publish time, and the packages' name fields are the only thing it touches — packages/* directory names, pnpm-workspace.yaml and every import specifier stay as they are.

2026-09-01 — assets.tcgdex.net sends access-control-allow-origin: *, and the proxy ships anyway

Decided by: Claude Fable 5 on behalf of @cheyras

Decision: the CORS question subtask 7 opened is answered: permissive. Measured with curl -D -, with and without an Origin header, on https://assets.tcgdex.net/en/base/base1/4/high.webp — 200, access-control-allow-origin: *, access-control-expose-headers: Content-Length,Content-Range; the OPTIONS preflight answers 204 with access-control-allow-methods: GET, OPTIONS. A cross-origin <img crossOrigin="anonymous"> therefore uploads as a WebGL texture without tainting the canvas. functions/image.ts ships regardless.

Why: only one of the three reasons for the proxy was ever about CORS. The other two hold whichever way the check landed: a volunteer-run CDN should not be hammered every time somebody scrubs a set, and — the one that matters for the corpus — subtask 4's frame registry resolves a framing from source URL + raster dimensions, so a URL under our control is what keeps that key stable when upstream re-encodes something. A green CORS result means dropping the proxy stays available later; it is not a reason to skip it now.

Implications: the frame registry's key is /api/image?p=… for anything the editor draws over, and it stays that way even if upstream's URL structure moves. The src= form exists so a recorded source URL passes through byte-identically. SSRF is closed by construction rather than by an allow-list check: the function only ever builds ${ASSETS_ORIGIN}/${path} from a path that matched a strict five-segment regex.

2026-09-01 — The client-side search index is ~700 KB, and is partitioned anyway

Decided by: Claude Fable 5 on behalf of @cheyras

Decision: the static search index is 601–741 KB raw / 75–223 KB gzipped at catalog scale, partitioned by normalized first letter into 27 buckets loaded on demand.

Why (the measurement, n stated): /search was a Postgres full-text query; static means an index in the browser, and subtask 7 flagged it as the one place in the read path with real size implications. It was measured rather than guessed: the real emitter was run over 20,500 synthetic cards / 41,471 printings / 184 sets — the catalog's own scale, taken from data/foil-pattern-cards.json — with cardId lengths drawn from the real measured distribution in data/foil-card-assignments.json (n = 341, mean 10.32 chars, min 6, max 15) and card-name length swept at 11/14/18 chars under two entropy models to bracket gzip. That range is the answer; the fixture bake measures 9.6 KB raw / 3.4 KB gzipped at 300 cards.

Implications: 700 KB is small enough to ship whole, so partitioning is not load-bearing — it is there so a first keystroke does not pay for the twenty-five buckets it excluded, and because a bucket is the unit that grows when the catalog does. data/search/index.json records the measured byte totals, so the decision stays answerable against a number. RUN-BAKE.md names ~2 MB raw / ~600 KB gzipped as the threshold that would justify revisiting it.

2026-09-01 — Ownership comes out of the editor; the replacement filters are contribution-shaped

Decided by: Claude Fable 5 on behalf of @cheyras

Decision: ownedOnly, the owned-only chip, the per-series owned counts, the per-set "N owned" and the per-card ownership badge are removed from the hosted editor — not hardcoded to false. They are replaced by has-mask / no-mask / has-window-geometry, answered from data/corpus-manifest.json rather than from a query parameter.

Why: there is no DeckPal account behind foilkit.deckpal.app, so an owned-only filter has nothing to filter against. A dead parameter threaded through four useQuery calls is worse than its absence, because the next person has to work out whether it does anything. And the replacement is not a substitute for the old filter — it answers the question a contributor actually has, which is "what work is and is not done".

Implications: the baked catalog shards must never carry a user column, a collection count or an internal id; tools/bake/guards.ts asserts that structurally by walking every emitted object for a forbidden key. The ownership dot on a card thumbnail became a MASK dot — the contribution fact in the same three pixels.

2026-09-01 — The editor's home screen is a queue, not a card picker

Decided by: Claude Fable 5 on behalf of @cheyras, on 3a's measurement

Decision: / renders the verification map's rule groups ranked by leverage (printings ÷ (exemplars + 1)). The card picker lives on /card and is what you reach for when you already know which printing you came for.

Why: 3a produced the ranking specifically to answer "is the editor a card picker or a queue", and the answer is in the numbers: the top group is modern-swsh / sheet / energy-symbols-ii / set governing 2,041 printings with zero human exemplars, and the top five all govern 500+ printings each. A picker asks which card you came for; most of the time the honest answer is "whichever one teaches the rule the most".

Implications: the queue draws no progress bar and no completion state, and the end-to-end run asserts there is no <progress> element anywhere. Nothing in this corpus is ever blank — a card with no human attention is guessed, not missing — so a completion bar would be a lie about both halves. Groups above a leverage floor are labelled as a regeneration pass rather than offered as a button, because that is a tool run against the corpus, not a thing a browser does.

2026-09-01 — A staged session's seed is immutable, which is what makes one correction per session free

Decided by: Claude Fable 5 on behalf of @cheyras

Decision: updateMaskSession has no path to session.seed. Every intermediate save updates pixels, geometry, uniforms and the comment; the seed is written once, when the session is created, and the single PUT at submit carries derivation { startedFrom, parent } from it.

Why: the old lab rewrote session on every Save so the mask just written became the parent of the next — ten saves on a card produced ten correction records, ten parent PNGs, ten diffs. Collapsing that to one needed no change to the provenance contract at all: writeMaskRecord reads the parent from disk at write time and derives derivation_method by diffing saved pixels against what the declared seed rasterizes to. Making the seed structurally unreachable turns "one correction per session" from a discipline into a property.

Implications: keep-mine on a conflict changes nothing about the payload — the correction lands against current upstream automatically, which is exactly what "reparented onto current upstream" means. The trade is ten weak training samples for one strong one per card, which is right for the corpus and is worth remembering when #10 recalibrates exemplar weights.

2026-09-01 — Conflicts compare the resolved answer, not the seeded file

Decided by: Claude Fable 5 on behalf of @cheyras

Decision: conflict detection runs two comparisons: the sha256 of the mask that answers now against the one stashed at seed time, AND the identity of the record that answered. Five outcomes — none, parent-changed, alias-moved, parent-vanished, parent-appeared — and three choices, keep-mine / take-theirs / re-trace. Never a merge.

Why: masks alias across variants by prior.scope, so upstream can grow a sibling mask that changes which record answers for the staged variant without touching the file that was seeded from. A sha comparison against the seeded file sees nothing. The pixels are not in conflict in that case; the provenance parent is, and a correction recorded against the wrong parent is a lie in the training signal.

Implications: the probe carries an identity as well as a hash, and the end-to-end run asserts the alias case separately from the sha case — with identical pixels on both sides, so a test that only moved the sha would pass while the bug shipped. Nothing auto-merges: two people painting the same alpha channel have no lines to reason about, and any automatic result is plausible-looking garbage nobody drew.

2026-09-01 — The undo stack is not persisted

Decided by: Claude Fable 5 on behalf of @cheyras

Decision: a staged session persists current pixels, the seed, and the parent sha. Not the undo history.

Why (measured): mask PNGs average 7.7 KB and the whole 96-file corpus is 738 KB — masks were never the constraint. MaskEditor keeps 12 ImageData snapshots at canonical raster, ~1.4 MB each, ~16 MB per card. Persisting three orders of magnitude more data to preserve a 12-step history through a tab close is a bad trade.

Implications: IndexedDB is still the right store (binary-friendly, async, quota in hundreds of megabytes), but it was chosen against the undo number rather than the mask number. The end-to-end run asserts a stored session is under 400 KB, so a future change that starts persisting history fails loudly.

2026-09-01 — A direct write is a commit; the writer list is compiled in

Decided by: Claude Fable 5 on behalf of @cheyras

Decision: functions/mask.ts materialises the corpus into /tmp from the repository head, runs the real writeMaskRecord against it, and commits exactly what changed through the git data API with force: false. The writer capability is a list in functions/_lib/writers.ts, mirrored in the editor, with a test that reads the editor's source and compares.

Why: running the same writeMaskRecord is what keeps derivation_method server-derived — a second implementation of that rule would be a second place for it to be wrong. Compiling the list in rather than reading it from an env var means granting the second writer is a reviewable commit rather than a dashboard click, and the mirror test means the UI can never offer a save the server refuses, or hide one it would have allowed.

Implications: @foilkit/forge's FRAMES_FILE had to become lazy and FOILKIT_FRAMES_FILE-overridable, because it walked for a pnpm-workspace.yaml at import time and threw inside a bundle that has none. The gate is not weakened: a raster matching no frame record still refuses to save. Attribution limitation, stated rather than hidden: the commit's committer is the project token's account and the author is the signed-in writer (<id>+<login>@users.noreply.github.com). Subtask 9's GitHub App collapses the two; this is the honest interim. The force: false on the ref update is the same principle as the conflict UI one layer up — if somebody pushed in between, refuse rather than discard.

2026-09-01 — Deletions are not stageable in v1

Decided by: Claude Fable 5 on behalf of @cheyras

Decision: deleteMask / deleteWindow / deleteCanon stay live for a writer-capability holder and are absent from the staging layer. There is no "stage a deletion" path and no flag to flip.

Why: a contributor's first available action should not be removing ground truth. A deletion also has no diff to review — the PR would be an empty file and a claim — so there is nothing for #9's pipeline to put in front of a reviewer.

Implications: re-open it when there is a reviewer flow that can weigh one. Deleting a canon file is recorded in its commit message as "this pattern is recorded as never canon'd again", because that absence is real signal for #11's queue rather than a reset to defaults.

2026-09-01 — Canon edits get a second session type, keyed by patternId

Decided by: Claude Fable 5 on behalf of @cheyras

Decision: the staging layer holds two session kinds — mask:<cardId>:<variantId> and canon:<patternId> — sharing a store, an export bundle and the conflict machinery, with their own eventual PRs.

Why: a canon file is per-pattern and global. It does not belong to any card, so it cannot ride the one-session-per-card rule without lying about what it is.

Implications: a canon conflict is sha-only — a full uniform snapshot has no aliasing — and offers keep-mine / take-theirs and no re-trace, because there is nothing to ghost underneath a slider. The hash is taken over a canonicalised (key-sorted) serialisation, so two files differing only in key order are the same canon.

2026-09-01 — A contributor's comment becomes PR body text, not a committed file

Decided by: Claude Fable 5 on behalf of @cheyras

Decision: the comment box no longer writes issues/foil/<id>/report.md + context.json into the repository. The text is stored in the staged session and carried in the export; it becomes the PR body when #9's pipeline ships.

Why: a stranger's note about their own change belongs in the review, not in the tree. Committing it also means every drive-by note is a permanent file somebody has to prune later.

Implications: stored and exported, never committed — so nothing is lost in the interim, and the eventual PR body can be assembled without re-deriving anything. A direct write puts the note in the commit message body instead, which is the same idea in the place a writer already has.

2026-09-01 — The provisional local diff is built, and is labelled provisional everywhere

Decided by: Claude Fable 5 on behalf of @cheyras

Decision: the editor computes an offline agreement number by rasterizing the seed client-side. It is shown as provisional, never persisted into a session, and never sent in a payload.

Why: the server decides derivation_method and agreement by diffing the saved pixels against what the declared seed rasterizes to, and the client must never label a mask. But the client owns the same rasterizer, and editing entirely blind for a whole session hides the one number that says whether the work is improving the shared rule or fighting it.

Implications: provisionalDiff.ts is a line-for-line port of forge's rasterizePriorAlpha and diffMask, because forge reaches node:zlib through its PNG codec and cannot enter a browser bundle. A parity test runs in Node — where both are importable — and asserts byte-equality across seven geometries including the inverted sheet, a zero-area rect and a radius larger than its box. If forge's rasterizer moves and the port does not, that test fails. That is the only thing that keeps a port a port.

2026-09-01 — data/frames.json is fetched, not bundled, by the write endpoint

Decided by: Claude Fable 5 on behalf of @cheyras

Decision: functions/mask.ts downloads data/frames.json from the repository head into its /tmp workspace and points FOILKIT_FRAMES_FILE at it, per request.

Why: the frame gate is what stops a mask being authored over a scan nobody can name, and a stencil cut for an unnamed picture is worse than no stencil. Bundling the registry into the function would freeze it at deploy time, so a data/frames.json update would authorise masks against numbers the corpus no longer uses — and the drift would be silent, which is the failure mode the gate exists to prevent.

Implications: one extra API read per write, on a path that already makes several. The registry and the pixels it authorises are the same generation, by construction.

2026-09-01 — The uncanon'd pattern count is 12, not 13

Decided by: Claude Fable 5 on behalf of @cheyras

Decision: data/corpus-manifest.json derives the uncanon'd list rather than trusting a count, and the derived answer is 12: ace-spec, acid-wash, crosshatch, disco, energy-symbols, energy-symbols-ii, ex-starfoil, pokeball-masterball, prism, prismatic-pokeball, rainbow-mirror, tcg-classic.

Why: subtask 5 recorded 13, which is 45 implemented patterns − 32 canon files. That arithmetic counts none, the no-foil recipe, which has no canon by definition and never will. The corpus itself has not moved.

Implications: the builder prints a FINDING: line whenever the derived list disagrees with the recorded count, and does not fail the build over it — a count is a claim and the corpus is the measurement. docs/HOSTED-EDITOR.md was corrected.

2026-09-01 — The deployment is built here, through the Build Output API

Decided by: Claude Fable 5 on behalf of @cheyras

Decision: tools/build-functions.mts esbuild-bundles each route into one self-contained .func and writes the whole deployment — static, functions and routing table — into .vercel/output. Vercel uses that directory verbatim. vercel.json is reduced to an install command and a build command; its rewrites, headers, functions and outputDirectory keys are deleted because the Build Output API ignores them and a misleading config is worse than none. The function sources move from api/ to functions/.

Why: the first production deploy answered 500 FUNCTION_INVOCATION_FAILED on every function — a boot crash, before any handler ran — while every test in this repository passed. Two independent packaging causes, both reproduced locally by booting the built artifact:

  1. @vercel/node transpiles each .ts file in place and does not rewrite import specifiers. functions/image.ts says from './_lib/http.ts'; the emitted image.js still says it; the file beside it is _lib/http.js. ERR_MODULE_NOT_FOUND, every function, always. The extension cannot simply change: Node's native TypeScript support is strip-only, so a relative import from a .ts file must end in .ts. Measured on Node 24, all three forms: ./dep.ts resolves, ./dep.js does not, ./dep does not. The source cannot use .js and the Vercel builder cannot use .ts.
  2. Workspace packages did not resolve either. nft traced @foilkit/forge and copied packages/forge/dist into the bundle, but emitted no node_modules and no symlink, so nothing mapped the bare specifier to the files sitting right there. mask, canon and window would each have crashed on first use; the report named only image because image was exercised first.

A bundle has no unresolved specifiers left to get wrong, which removes the category rather than the two instances.

Implications: esbuild becomes an explicit root devDependency — a build tool, named as one; packages/** still has none. The api/ → functions/ move is not cosmetic: with the sources in api/, Vercel's zero-config detection ran its builder as well as ours, both wrote into the same .func directory, and which handler the .vc-config.json named was a race. The routes are still served at /api/*. The runtime is pinned to nodejs22.x on both sides because engines: { node: ">=22" } makes Vercel override the project's 24.x setting.

2026-09-01 — A missing environment variable is a named 503, checked first

Decided by: Claude Fable 5 on behalf of @cheyras

Decision: every write and auth handler calls refuseIfUnconfigured() at the top, before a cookie is parsed or a repository is read, and answers 503 with a missing: [...] array naming the variables. /api/image requires no environment at all. /api/auth/signout requires none either, so a half-configured deployment can still clear a stale cookie.

Why: a boot crash, a 500 and a missing variable look identical from a browser and mean completely different things to whoever has to fix it — the code was packaged wrong, the code hit something unexpected, or the code is fine and setup is unfinished. The first deploy conflated all three, and the one useful signal ("you have not set FOILKIT_GITHUB_TOKEN yet") was indistinguishable from a packaging failure. Checking configuration first also stops a signed-out visitor being told to sign in before hitting the same refusal anyway.

Implications: tools/verify-functions.mts asserts the whole ladder — empty environment, secret-only, and the real state production is in — against the BUILT artifact rather than the source, and CI runs it on every push. Card scans work on a deployment with nothing configured, which is deliberate: they are what a visitor sees before they have any reason to sign in.

2026-09-01 — The card face was rendering upside down, and blank bases hid it

Decided by: Claude Fable 5 on behalf of @cheyras

Decision: FoilStage makes the face texture's orientation explicit on both decode paths — createImageBitmap(..., { imageOrientation: 'flipY' }) and a mirrored drawImage in the canvas fallback — and sets flipY = false so three cannot flip it again.

Why: the plane's uv.y = 0 is its bottom edge and the vertex shader passes uv through untouched, so the texture's v = 0 must be the image's bottom row. Texture.flipY defaults to true and would arrange that for an image or a canvas, but not for an ImageBitmap, which uploads as encoded. Every real card scan rendered vertically mirrored. Measured rather than argued: the render was correlated against the source scan in four orientations, and as-is scored -0.37 while flipped-Y scored +0.24; after the fix the two swap to +0.25 and -0.37.

Why nothing caught it: the render-parity harness and the stage acceptance both run on blank bases (uScanBase 0), where the face is a flat colour and a vertical flip is invisible by construction. The editor's own end-to-end run stubbed the scan with a 1×1 PNG, which is the same blind spot in a different costume. It took a real scan on a real deploy to see it.

Implications: the fixture scan in apps/editor/e2e/run.mjs is now 64×88, white on top and black on the bottom, and an assertion samples the rendered canvas and requires the top band to be brighter. The guard was checked against the bug it was written for — reverting the fix fails it. A flat-base test can never again stand in for "the card renders correctly".

2026-09-01 — The env table said 500/502; the code has always said 503

Decided by: Claude Fable 5 on behalf of @cheyras

Decision: DEPLOYMENT.md's environment table now says 503 not_configured for FOILKIT_SESSION_SECRET, the OAuth pair and FOILKIT_GITHUB_TOKEN, and notes that 502 write_failed is a different answer entirely — the token was present and GitHub refused the commit.

Why: config.ts sends 503, tools/verify-functions.mts asserts 503, CI runs it, the same document's own failure-modes section says 503, and the live site answers 503. The table was the only thing still saying 500 and 502 — and it is the part somebody reads while deciding whether they are looking at a packaging failure or an unfinished setup, which is the exact distinction the 503 exists to draw.

2026-09-01 — functions/_lib/writers.test.ts exists, because two files cite it

Decided by: Claude Fable 5 on behalf of @cheyras

Decision: the client/server writer-list parity check moves out of session.test.ts into writers.test.ts, the file writers.ts and capability.ts both name. It also now asserts that the two isWriter IMPLEMENTATIONS agree, not only that the arrays match, and that the list is non-empty.

Why: the citations pointed at a file that did not exist. That is harmless until somebody grants the second writer, edits one list, greps for the named test to run it, and finds nothing. The matching RULE is duplicated as well as the list, so a list that agrees while the rule does not would let the UI and the server disagree about CheyRas while the test said they were in step.

Implications: the editor's copy is still read as SOURCE TEXT rather than imported — it is front-end code whose import graph is free to grow a React or Vite dependency, and a parity test that fails because the editor imported something is a test people delete. Checked against the bug it exists for: adding an entry to one list only fails it.

2026-09-01 — Every catalog artifact carries the bake stamp

Decided by: Claude Fable 5 on behalf of @cheyras

Decision: tools/bake/emit.ts writes version + generatedAt + source + resolverVersion onto the per-series and per-set shards, not only onto catalog/index.json and search/index.json. Card detail is read out of the set shard, so that covers it too.

Why: the spec says every artifact is stamped. A shard that cannot be dated cannot be told apart from a shard out of a different bake, and "they shipped together, check index.json" is an answer that is wrong exactly when a partial deploy or a hand-copied file is what you are chasing. About 70 bytes against set pages that run 15 KB.

Implications: the change is to the TOOL. The shards committed under data/catalog/ predate it and are NOT rewritten — adding a stamp to an existing file would be inventing a date. They restamp on the next bake, which is the only thing that can honestly stamp them. emit.test.ts asserts the stamp on every catalog artifact and on both index files; the editor's shard types carry the fields as optional, because unstamped shards exist in the wild today.

2026-09-01 — A staged session's pixels beat upstream, unconditionally

Decided by: Claude Fable 5 on behalf of @cheyras

Decision: when a staged session for (cardId, variantId) holds a PNG, that PNG owns the mask canvas. The saved-hand-mask loader in FoilLab.tsx does not fetch and does not draw while such a session exists — and it does not run at all until the session store has been read (staging.loading). Upstream is loaded only when no staged pixels exist, or deliberately, through the conflict flow's take-theirs / re-trace.

Why: the loader was unguarded. On every reload it fetched the committed mask and drew it over a canvas the staged-restore effect had already painted — a network fetch beats a data-URL decode, and the restore had latched restoredFor and would not run again. The session RECORD survived; the PIXELS did not. Worse, staging.save re-reads the store, and the store's list was a dependency of the loader, so pressing Save to session re-ran the fetch and then wrote the upstream pixels over the contribution. Measured live: provisional diff 61.1% (+10,387/-39,869) before a reload, 66.4% (+0/-39,869) after — exactly the upstream mask's own manifest agreement. A contributor's staged PNG is the only copy of that work in existence; nothing may overwrite it implicitly.

Implications: staged is now DERIVED from the store rather than mirrored in useState — the mirror lagged the selection by one commit, so every guard written against it read the previous card's session for one render. The restoredFor latch resets on card change, or leaving a staged card and coming back showed the pixels of whatever was opened in between. The editor's E2E asserts the provisional diff NUMBERS are unchanged across staging and across a reload, and that a second Save to session leaves the stored PNG byte-identical. The existence of a session is not asserted as a proxy for any of that, because it was true throughout the bug.

2026-09-01 — A pending deep link holds the picker's auto-select chain

Decided by: Claude Fable 5 on behalf of @cheyras

Decision: FoilLab holds the card id a deep link asked for until the browse chain has been re-derived for it, and the three auto-select effects (series -> set -> card) do not run while it is held. It is released when the loaded detail agrees with the selection, or immediately if the catalog has no entry for the id — in which case the surface says so instead of silently substituting a different card.

Why: a deep link arrives with seriesSlug and setId empty, because only the card detail knows what they are. The auto-selects fill empty slots, and the SET step fills setId by CLEARING cardId. It needs no network round trip and the detail query needs one, so it won the race, landed on the hardcoded base1 / base1-8 default, and the URL-sync effect then rewrote the address bar to match. The contributor was working on a card nobody sent them. Measured live: 3/3 wrong for queue "Work this" picks outside base1, 2/4 wrong for cold deep links.

Also fixed here: Catalog.card() derived the set shard as "everything before the last hyphen" and gave up if that 404'd. The bake deliberately TOLERATES ids that break that round trip — it counts and prints them rather than failing a whole catalog over one malformed promo id — so the reader has to be as forgiving as the writer. Otherwise such a card is unopenable, and the auto-select fallback is exactly what pressing "Work this" on one of them looks like. It now walks hyphens right-to-left, bounded at four candidates.

Implications: the editor's E2E asserts the address bar and the card on screen for several queue picks and for a cold deep link into another series. And, because a localhost static server answers every file in under a millisecond and therefore cannot lose this race, the harness can now delay chosen artifacts: everything the DEFAULT chain needs stays instant while the target's shards go cold. That is the production shape of the race and the only shape in which the bug is visible at all — without it the new assertions passed against the unfixed code, which is the worst outcome a regression test has.

2026-09-01 — A refused write says so, in the server's words, and keeps the pixels

Decided by: Claude Fable 5 on behalf of @cheyras

Decision: the direct-write paths surface their outcome. saveMask, deleteMask and saveWindow no longer swallow the throw: the error line shows the message api.ts's writeError already parsed out of the server's response, and says that nothing was discarded. A failed save re-asserts the dirty flag and touches neither the canvas nor the session; a failed DELETE now clears nothing at all, where it used to clear the whole surface and report a success the repository never granted.

Why: catch {} with the comment "surfaced by the dirty flag remaining" was not a surface. A writer pressing Save against a 503 saw a button that behaved exactly as it does on success, and the server's actual sentence — which names the missing variable — was parsed and then thrown away.

Implications: three stale strings went with it ("is the branch api up?", "is the 3712 api up?", and "Saved to issues/foil/" for a note that now goes to a staged session). The failure text is the server's rather than ours, so a new refusal reason needs no client change to become readable.

2026-09-01 — Per-card overrides are session contents; the button that pretended otherwise is gone

Decided by: Claude Fable 5 on behalf of @cheyras

Decision: the "Save card overrides" button is removed, and putOverride / deleteOverride are deleted from api.ts rather than left as stubs. Adjusted uniforms ride the staged session — stageMask already writes them — which is what spec 8 says per-card overrides are. The panel states this, and states plainly that a writer's Save commits mask pixels rather than sliders.

Why: putOverride threw unconditionally. There is no /api/override route, because data/foil-overrides/ has never held a record, and a PUT with no reader would be a promise rather than a feature — but it was still wired to a live button. The one thing it reliably produced was a red "Override save failed" that blamed a server for refusing a request it had never been sent. A button that cannot succeed is worse than no button, and a client method that can only throw is a trap for the next caller.

Alternative considered and declined: making the button call stageMask(). It would work, but it would create a mask session — carrying the canvas PNG — for somebody who only meant to save sliders, and a writer has no session UI on this surface. Saying what actually happens beats inventing a path to make a button true.

2026-09-01 — A 404'd scan says so instead of rendering a black card

Decided by: Claude Fable 5 on behalf of @cheyras

Decision: the card pane HEADs /api/image for the printing on screen and, on a 404, says "scan unavailable upstream — this printing has no image at the catalog's path"; on a 502 it says upstream answered with something that was not a scan. Only OUR proxy is probed — a cross-origin URL's status is not readable and guessing at one would put a wrong explanation under a card that renders fine — and a network failure says nothing at all.

Why: /api/image already distinguishes those two cases deliberately. Its own header gives the reason: a contributor staring at a blank editor needs to tell "no scan exists" from "the CDN is serving an error page with a 200 on it". The editor was throwing that distinction away and rendering a void, and a void reads as a broken editor rather than as a gap in a volunteer CDN.

Implications: one HEAD per card opened. HEAD is the method /api/image documents for exactly this question — every header and no body.


2026-09-05 — A staged session becomes a pull request, opened by a GitHub App

Decided by: Chey Rasmussen (plan), Claude Fable 5 (implementation)

Decision: /api/contribute is the contribution pipeline. Any signed-in GitHub account may POST a staged session to it; the function validates it server-side, mints a one-hour installation token from a GitHub App scoped to cheyras/foilkit with contents:write + pull_requests:write, commits the session to contrib/<login>/…, and opens or updates one pull request.

The App is a second, independent credential beside the direct-write PAT rather than a replacement for it. Both paths ship. functions/_lib/config.ts gives each its own 503 ladder naming its own variables, because a deployment can legitimately have either, both or neither and each combination has a different honest answer.

Why: a PAT is a long-lived bearer secret belonging to a person, valid until somebody notices it leaked; an installation token is minted per request family and expires in an hour. More importantly the App is its own ACTOR, so a contribution pull request is opened by the App rather than by the maintainer's account — which is what makes "cheyras reviews a contribution" a real review instead of the maintainer approving himself.

The direct write stays because routing Chey's own work through submit-and-review would put a queue between him and his own repository for no gain, which is the same reason the writer capability exists at all.

Implications: three new environment variables, declared in DEPLOYMENT.md in this commit (contract B11's shape). The maintainer creates the App by hand — DEPLOYMENT.md names the exact permission set, and it is exactly two. The commit is authored by the App bot with a Co-authored-by: trailer for the contributor, which is the mechanism that puts their avatar on the commit and their name on the pull request. People contribute where their name shows up.


2026-09-05 — The App's private key lives in Vercel's encrypted env, not a separate secrets manager

Decided by: Claude Fable 5, on behalf of @cheyras

Decision: a deliberate deviation from the plan, which said "private key in a secrets manager, not env". FOILKIT_APP_PRIVATE_KEY is a Vercel sensitive environment variable.

Why: Vercel's encrypted environment variables are this deployment's secrets manager. The value is write-only once set, vercel env ls prints names and targets and never values, and it is injected into the function process at run time — which is the same property a secrets manager provides. A separate service would add an outbound dependency on the request path, a second credential to bootstrap it with, and a new failure mode, in exchange for nothing this deployment does not already have.

The property the plan was actually protecting — "the key is never in a file, a log or a transcript" — is protected here and is TESTED: functions/_lib/app-auth.test.ts asserts that a malformed key produces a 503 naming the variable and that the message never contains the value, and that a failed token exchange never echoes the JWT back even when GitHub's error body contains it.

Implications: rotation is vercel env rm + vercel env add + a redeploy, which is a maintainer action and needs a deploy. If foilkit ever grows a second runtime that needs the same key, revisit — one key in two env stores is the point at which a real secrets manager starts paying for itself.


2026-09-05 — The GLSL compile gate is a GitHub Action, not a WASM validator in the function

Decided by: Claude Fable 5, on behalf of @cheyras

Decision: the submit endpoint validates the assembled shader structurally — the pattern ABI (vec3 foilPattern(vec2, vec2)), brace and paren balance across PREAMBLE + pattern.glsl + MAIN, the absence of a #version directive, and that every uniform the canon file sets is actually uniform-declared by the assembled source. The real compile happens in the pr-evidence GitHub Actions workflow, which renders the submitted state through headless Chromium on SwiftShader and fails the job on any page or console error.

Why: the plan asked for a WASM GLSL validator in the function "if a workable zero-native-dep option exists". It was evaluated and there is not one, and the reason is DIALECT rather than size:

  • foilkit's composite is GLSL ES 1.00 — varying, attribute, texture2D, gl_FragColor, no #version. That is WebGL 1, which is what @foilkit/three targets.
  • @webgpu/glslang (5.1 MB unpacked, last published 2021) compiles Vulkan GLSL and requires #version 450 with layout qualifiers. It would reject every shader in this repository. A validator that fails on valid input is worse than no validator, because its failures are indistinguishable from real ones.
  • naga-wasm is unpublished on npm; naga's GLSL frontend supports ES 3.00 and desktop profiles, not ES 1.00.
  • A pure-JS parser (@shaderfrog/glsl-parser) parses but does not type-check or link, so it would not catch the failures that matter — and it would be the first runtime npm dependency in a workspace whose packages have none.

SwiftShader is not a fallback from any of those. It is a real GL driver compiling the real dialect, which is a strictly stronger gate than the best of the rejected options would have been.

Implications: the compile is a pull request check rather than a submit-time refusal, so a shader that does not link opens a pull request and then fails on it. That is the cost and it is acceptable: the structural checks catch the failure modes a contributor can actually cause (a canon file naming a uniform its recipe does not declare is the realistic one), and a recipe whose GLSL does not compile is a code change, which goes through review anyway. functions/_lib/validate.test.ts runs the structural check across the WHOLE recipe corpus, so a recipe added with unbalanced braces fails the unit suite rather than a browser.


2026-09-05 — Render evidence lives on an orphan pr-evidence branch, never on the pull request's own

Decided by: Claude Fable 5, on behalf of @cheyras

Decision: the 8-frame tilt sweep is pushed to an orphan branch named pr-evidence, under pr-<number>/<name>.png, and referenced from a single updated-in-place pull request comment by its raw.githubusercontent.com URL with ?v=<head sha> to bust GitHub's image proxy cache. It is uploaded as a workflow artifact as well, always.

Why: the requirement is that the strip renders inline in the pull request. Two approaches do that, and the other one — committing the strip into the pull request's own branch under evidence/ — was rejected for two reasons that compound:

  1. It merges into main unless somebody deletes it, and a merge-cleanup step nobody is blocked on is a step that gets forgotten. The corpus would slowly accumulate generated PNGs beside the measurements.
  2. It puts a generated binary the contributor did not author into the diff that is the review. A reviewer reading "three files changed" should be reading three files the human changed.

The orphan branch shares no history with main, never merges anywhere, holds one directory per pull request, and can be deleted wholesale without touching a single contribution.

Implications: it depends on cheyras/foilkit being public — GitHub's image proxy can only fetch a raw URL from a public repository. If foilkit ever goes private the images stop rendering inline; the strip is still on the branch and still an artifact, and the comment should then name the artifact instead. Written down in DEPLOYMENT.md's failure table so the next person does not have to rediscover it.


2026-09-05 — The tilt sweep renders on the blank base, because F2 applies to generated pictures too

Decided by: Claude Fable 5, on behalf of @cheyras

Decision: tools/parity/tilt-strip.mjs renders the submitted mask over the blank card base — the parity host's four flat tones — never over a card scan. The host page's new maskUrl parameter binds the mask as uMaskTex; there is deliberately no parameter for a face image.

Why: AGENTS.md F2, the standing ownership rule: ship nothing we do not own outright, not in data/, not in a demo, not in a test fixture. The evidence strip is COMMITTED — to the pr-evidence branch — so a strip containing a rendered card scan would put third-party pixels into this repository, which is exactly the thing that makes a CC0 dedication unreliable. That the pixels are composited rather than copied changes nothing about who owns them.

The blank base is also the better picture on its own merits. It shows how the foil behaves inside the region the human drew, at eight angles, with no printed ink competing for attention — which is the thing under review.

Implications: a reviewer cannot see the mask registered against the artwork from the strip alone. That is what the .diff.png committed beside the mask is for (green added, red removed against the era rule), and what opening the card in the editor is for. If a scan-registered view is ever wanted, it belongs in the editor where the scan is fetched live and never stored — not in a committed artifact.


2026-09-05 — A mask's "alpha-only content sanity" checks the alpha, and deliberately not the RGB

Decided by: Claude Fable 5, on behalf of @cheyras

Decision: validateMask requires that a submitted mask decodes, is exactly 504 × 704, matches its declared dimensions, has some coverage, and has less than 98% coverage. It does not check the RGB channels.

Why: the content of a mask lives entirely in its alpha channel — MAIN reads texture2D(uMaskTex, …).a and nothing else, so RGB is display tint for humans looking at the PNG. Measured against the committed corpus: most masks carry a single tint triple, and base1-5/19.png carries 1419 distinct triples from a canvas composite. Those masks render identically. A check that rejected varied RGB would be enforcing a convention rather than a fact, and it would reject real data already in the corpus.

The two bounds that ARE enforced are the ones with meaning. A mask covering nothing has no measurement in it. A mask covering everything is what the renderer already does with uMaskTexOn = 0, so committing one adds a file that changes nothing while claiming a human looked. The committed corpus runs 0.157 to 0.537 coverage, so the 0.98 ceiling is far outside anything a hand mask produces.

Implications: the ceiling is a named constant with a test asserting the MARGIN rather than the number, so a future full-face convention is a decision somebody makes rather than a limit somebody trips over.


2026-09-05 — One canon-file composer, shared by the direct write and the pull request

Decided by: Claude Fable 5, on behalf of @cheyras

Decision: functions/_lib/canon-entry.ts composes and serialises a canon file, and both functions/canon.ts (direct PUT) and functions/contribute.ts (pull request) call it. Identical uniforms produce identical bytes on both paths.

Why: two write paths reaching one file format is two dialects of that format, eventually. Extracting the composer also fixed two fields the direct write had been dropping on every rewrite, both silently:

  • tunedUnderContract. tools/parity/data-receipt.mjs FAILS on a canon file without it, and all 32 committed files carry it. A save that dropped it succeeded, and CI broke on the next push with no obvious connection to the save that caused it.
  • frozen. That is a human decision — "these numbers are settled, stop re-tuning them". AGENTS.md F4: a machine write may never overwrite one. Dropping it on a rewrite is exactly that rollback, performed by a save that looked like it only touched uniforms.

Implications: tunedUnderContract is now stamped with the CURRENT law for a live tuning session (a human just chose these numbers under this main()) and preserved for a mechanical rewrite. contract — the law the file is READ under — is still carried through untouched, which is what it was always for.


2026-09-05 — Correction: encodeURIComponent cannot encode a nested branch ref

Decided by: Claude Fable 5, on behalf of @cheyras

Decision: functions/_lib/pr.ts encodes a branch name for a URL path with refPath() — per segment, slashes intact — rather than with encodeURIComponent.

Why: encodeURIComponent('contrib/octocat/x') is contrib%2Foctocat%2Fx, and GitHub's git-ref routes 404 on that: the ref is a path, not a parameter, and heads/contrib/octocat/x has to arrive with its separators. github.ts has the same construction and is not wrong, because the direct-write path only ever names main — which is precisely why nobody would have noticed by reading it.

Recorded because of HOW it was found: the mocked-GitHub test in functions/_lib/pr.test.ts routed on the exact path and failed on the first run. A test that asserted "the promise resolved" would have passed against a mock that answered anything.

Implications: any future call that puts a ref in a path uses refPath. The pull request lookup still uses encodeURIComponent, correctly — head=owner:branch is a query parameter and the slashes there are data.


2026-09-05 — The tilt strip reads the canvas; page.screenshot is not portable enough to gate a merge

Decided by: Claude Fable 5, on behalf of @cheyras

Decision: tools/parity/tilt-strip.mjs captures each frame by calling toDataURL() on the renderer's own canvas — armed by window.__grabNext() and serviced by the render loop in the same task as the draw, while the drawing buffer is still valid — and crops the card rect in Node from __cardRect. tools/parity/run.mjs keeps using page.screenshot({ clip }) and is unchanged.

Why: page.screenshot asks the BROWSER COMPOSITOR for a frame. On a GitHub Linux runner that call logged fonts loaded and then sat until it timed out; on a Windows Chromium build it returned fine. Three CI runs went into the diagnosis, and neither of the first two hypotheses was right:

  1. animations: 'disabled' makes Playwright wait for the page to settle, and settling includes an animation frame this harness has stubbed. Removing it changed nothing.
  2. A page whose rAF loop has stopped calling itself never asks for a compositor frame. Restoring the real requestAnimationFrame at the fixpoint — which was proved not to move a pixel, sha256 b905f883968bb761… before and after — changed nothing either.

The compositor is not part of the measurement. It contributes a font pass, a device-scale negotiation, a viewport and a surface capture, none of which the card render depends on, and all of which vary by platform. Taking it out of the path deletes the class rather than the instance.

Implications:

  • The strip is no longer byte-comparable with anything produced by the screenshot path. That is fine and is stated in the file header: determinism is a run-to-run property, and it holds — a control pair of base1-4/15 gives 8cc84702a88a5d94… twice.
  • preserveDrawingBuffer stays off. Turning it on would change the renderer's configuration for every consumer of this page, the moving receipt included. Arming a flag the loop services is what makes it unnecessary.
  • The card rect is now arithmetic (the page's own inverse of its fit() projection) rather than a boundingBox(), so the crop cannot depend on layout, scrollbars or a window manager.
  • run.mjs has never been run headlessly on Linux. apps/demo/acceptance.mjs screenshots happily in CI and does not stub rAF, which is why nobody had met this. If the moving receipt is ever run on a runner and hangs, this entry is the answer — and switching it to canvas capture is a separate decision that needs its own control pair, because its recorded sha256s are compared against other builds of the same modules.
  • Found by opening a throwaway pull request and watching the workflow. Nothing short of that would have found it: the offline structural check, actionlint, shellcheck and four green local renders all passed the whole time.

2026-09-06 — Exemplar weight follows VERIFICATION, not authorship (sidecar v5)

Decided by: Claude Fable 5, on behalf of @cheyras

Decision: EXEMPLAR_WEIGHT becomes the owner-verified row of a table keyed on (derivation_method × provenance tier), EXEMPLAR_WEIGHT_BY_TIER. Three tiers: owner-verified (a writer-capability holder authored or verified the pixels — the historical weights, unchanged), contributor (a merged contribution nobody with the capability has verified), and unattributed (machine output, or a v5+ record with no author). The last two are zero across every method.

Sidecar v5 adds two recorded fields and one derived one:

  • author — RECORDED, server-side, from an identity the route already verified. The direct write takes it from the cookie requireWriter just checked; the contribution path takes it from the session, which the contributor cannot contradict because the App composes the commit.
  • verification — written only by a writer-gated route (PATCH /api/mask, or corpus.ts verify --as locally), and re-checked on every read: deriveTier honours it only when verifiedBy holds the writer capability.
  • provenanceTier — DERIVED on every normalizeSidecar, exactly like authorship / reviewStatus / frame. A hand-edited value is overwritten.

The writer list moved to @foilkit/core so forge can run that check in a CLI with no HTTP request in sight. functions/_lib/writers.ts re-exports it and is still the boundary; the editor's copy is still a separate literal reconciled by writers.test.ts.

Why: The weights were calibrated when the only hand in the corpus was the owner's, so hand meant both "a human painted this" and "this is how the card truly is". Step 9 shipped a pipeline where strangers open pull requests. Those became two claims, and only the first is visible in the pixels — which is the one thing this module has always insisted on. An unreviewed stranger's mask at weight 1 would not be a mislabelled file; it would be an era's rule derived from work nobody looked at.

Zero rather than "low", which the spec left open. Three reasons, in order of how much they cost:

  1. learnPolicy takes a weighted mean over a pool that is single-digit today and crosses a hard threshold at voteThreshold 0.5. With Σweight ≈ 2–3, even w = 0.1 moves a class share several points — enough to flip carriesFoil at the margin. "Low" is not "harmless"; it is "changes the answer quietly".
  2. Any weight above zero admits the mask to the pool at all, because isExemplarEligible is a > 0 gate. Admitted means it is cited as an exemplar on generated masks, counts toward exemplarsInGroup, and lowers leverage = printings ÷ (exemplars + 1) — the number that decides where the next hour goes. Unreviewed work must not make a rule group look served.
  3. Zero is reversible in one owner action and the full weight comes back. A nonzero default is not: by the time anyone notices, it has moved every derived number downstream.

3a's finding that most current evidence is ai-corrected at 0.6 argues the same way. The pool is already thin and already discounted; thin is a reason to be more careful about what joins it.

Implications:

  • The committed corpus is unchanged in weight. corpus.ts migrate ran over all 20 sidecars: 15 owner-verified (the human work), 5 unattributed (the window-artgate proposals, which are weight 0 by method anyway). The exemplar pool is 15 before and 15 after. v1–v4 compatibility is permanent.
  • The historical inference is MATERIALISED at the version bump, not left to expire. deriveTier's pre-v5 branch reads absence-of-author as the owner (RELICENSE.md records the sole-author fact), and that branch is keyed on the version — so a schema upgrade that only renumbered the file would have rewritten the whole corpus as unattributed, weight 0, and emptied the pool every rule here is derived from. upgradeSidecarRecord stamps HISTORICAL_AUTHOR; a test asserts that login still holds the capability, so the constant and the list cannot drift apart in silence.
  • A pre-v5 ai record is attributed to its GENERATOR, not to the owner. Weight 0 either way; what changes is that a mask never displays a green "owner-verified" beside its own amber "AI · UNREVIEWED".
  • A BUG WAS FOUND AND FIXED BY RUNNING THE MIGRATION FOR REAL. corpus.ts migrate synthesized a fresh single-entry lineage unconditionally. That was correct for exactly as long as the command only met pre-v3 records (they have none); the v4→v5 pass was the first to meet records that DO have one, and the first run deleted the entire 4b frame-migration history from all 20 sidecars. Caught by reading the diff, not by any test. The upgrade is now a pure function (upgradeSidecarRecord) with a test that asserts the lineage survives — a migration that lives only inside a CLI is a migration nothing can test.
  • The forged-field defence has three layers, and the third is honest about its limit. (1) validate.ts refuses any submission carrying author, verification, provenanceTier or a derived label — a deep scan, because nobody who wanted to lie would put it at the root — with a named 422 before a branch exists. (2) deriveTier re-checks verifiedBy against the writer list on every read, so a block hand-committed in a fork PR grants nothing. (3) A hand-crafted PR could still name a real writer over pixels no writer saw. Nothing in the file can disprove that; what closes it is that such a PR did not come from the App, so a human reads it, and the lie is in the diff.
  • exemplarWeightOf fails CLOSED and deliberately does not reuse exemplarWeightFor's owner-verified default. That default is right for a method named in the abstract ("what is a hand mask worth at best") and actively dangerous for a record: a fixture or a stale value with no tier would otherwise be silently treated as ground truth. Two functions, two questions.
  • Promotion rides the existing write path. PATCH /api/mask is writer-gated, takes the verifier from the cookie, and asserts that only the .json changed before committing. corpus.ts verify --as <login> is the repo-side equivalent and refuses a login without the capability rather than writing a block every read would ignore. A later save clears the verification: what a writer approved is not what is in the file any more.
  • A submission by the OWNER through the contribution path is contributor, not owner-verified. The one place the two paths differ in outcome for the same person, and it is deliberate: a proposal that promoted itself on the strength of who sent it would make the pipeline untestable by the person who most needs to test it.
  • Canon files grow the same block (startedFrom, changed, author, verification, tier) with the weight semantics documented as narrower: a canon file is not training input, so the tier carries no numeric weight, and frozen remains the stronger separate statement. A canon save never carries a verification forward — the file is a full snapshot, so the numbers a writer approved are not the numbers now in it.
  • No environment variables were added, so DEPLOYMENT.md is unchanged.
  • data/foil-verification-map.json gains exemplarsByTier and poolAwaitingVerification in the code, but the committed artifact needs a Postgres-backed re-bake to pick them up. Not gated by CI, same as today.

2026-09-06 -- Correction: the residual provenance hole is three doors, not one

Independent verification of the tier rework reproduced two UNDOCUMENTED shapes that reach owner-verified weight from a hand-crafted fork PR, alongside the one the entry above documented: (2) an author block byte-identical to HISTORICAL_AUTHOR (login: cheyras, via: local-cli), and (3) a current-era record whose version field is edited down to 3, which the historical inference cannot distinguish from a genuine legacy record. Neither is reachable from the App (both write paths compose sidecars server-side and hardcode version); all three require a fork PR whose diff a human reads. The residual-hole note in provenance.ts now names all three, the asAuthor docstring no longer overstates what a shape check proves, and a dedicated test pins each door as known-and-accepted so a behavior change surfaces as a test failure rather than a silent doc drift. Review guidance: in any sidecar diff, version, author, and verification are claims the diff itself cannot prove.

2026-09-06 — The contribution queue is generated, and a doc's count is a claim

Decided by: @cheyras

Decision: data/task-queue.json is a build step (tools/build-task-queue.mts, every build, no database) that turns six committed artifacts into one impact-sorted list of task cards, and the hosted editor's landing page renders it. The leverage table stays as one section of that list rather than being replaced — it is the impact spine, in its own units.

Six sources, seven card types: approximated recipes (PATTERNS[].implemented), canon-less patterns (corpus-manifest.json), standing verification nays (a new committed datum), uncorrected machine masks ranked by 1 − diff.agreement, window-scope groups at zero exemplars, untargeted research residuals, and the bake's empty-pool diagnosis rendered verbatim.

Why: Every one of those was already recorded in this repository and invisible from the home screen, so a contributor who could not draw a mask had nothing to read. Generating rather than writing the list is what makes it survive: there is no hardcoded task anywhere in the builder, and task.source names the file and field each card came from, so a generated list cannot be mistaken for one somebody made up.

Where a document states a COUNT, the builder derives the real number and emits a reconciliation row either way — printing a FINDING: line when they disagree, and not failing the build. The document is a claim; the corpus is the measurement. Failing on every stale sentence would mean nobody could commit a measurement until they had also rewritten the prose. This is the same discipline build-corpus-manifest.mts already applied to the canon-less count, generalised.

Implications:

  • Three doc counts are stale today, and the queue says so on the page. "5 approximated types" (SHADER-CONTRACT.md:290-295) is 1 — four of the R3 list shipped dedicated recipes in R3-MISC. "13 canon-less" is 12, the long-standing none correction. "4 standing nays" (VERIFICATION.md:61-73) is 5, and only two of the original four are in it: R3-MOTION broke starlight (:677), R3-GLYPH broke radiant-collection-dots (:754), and radiant (:680), ace-spec (:756) and prismatic-pokeball (:757) were added. VERIFICATION.md:840-843's claim that "zero approxVia fallbacks remain" is also refuted by the code — big-glitter still carries one.
  • data/verification-verdicts.json is new, and is the one thing that could not be derived. The verdicts are VERIFICATION.md prose and their machine-readable form is outside the repository (:31-34), so the extraction is by hand, once, with doc line numbers per row. Update it in the same commit as any future wave; the builder refuses a row that names a non-pattern. Its $supersededClaim block records the four-nay claim it replaces rather than deleting it, because other documents still make that claim.
  • A still-frame-blind nay asks for a live tilt, not a shader round. The flag is in the datum, it selects the live-tilt skill, and the card renders the caveat in words. VERIFICATION.md:759-764 names Chey's live tilt as the arbiter for motion claims; ace-spec and radiant are pixel-refuted in the doc itself. Answering these with GLSL is the documented wrong move, so the queue says so where a contributor will read it.
  • An outranked empty pool is NOT ranked by the printings it loses. Its impact is null, not 1,818. A higher-ranked row legitimately wins every one of those printings — cited rows routinely describe different physical layers of the same card — so ranking by that number would sort "usually nothing to fix" above real work and read as an invitation to go and win them back. The number stays on the card as detail, under the do-not-flip-a-winner guard.
  • The queue lives in the BAKE directory, not next to the corpus manifest, because two of its six inputs are bake outputs. It follows the same FOILKIT_BAKE seam copy-data.mjs and vite.config.ts use, so the fixture site gets a real queue built from fixture numbers and the whole surface is exercisable with no database. A missing bake is recorded in bakedInputs, never fatal; a missing committed input is fatal.
  • CI gains build-task-queue.mts --check, beside the corpus manifest's, for the same reason: a committed queue that no longer matched its inputs would send contributors at work that is already done.
  • No environment variables were added, so DEPLOYMENT.md is unchanged.

2026-09-06 — The ex13/ex16 residual closure was already committed; ex11 is queued, not closed

Decided by: @cheyras

Decision: The step-11 plan asked whether the two catalog-closable residuals — the six Holo Rare basic Energies of ex13 and ex16 resolving to vertical-sheen-rainbow — could be closed from committed catalog data. They already were, on 2026-08-08 in the R7 pass: both known_residuals entries carry a resolved field naming the exact card ids (ex13-105..110, ex16-103..108), and two cardIds rows in data/foil-card-assignments.json assign the pattern. 55 residuals recorded, 12 closed, 43 open. No resolver digest changed in this work, because no assignment row was edited.

A THIRD residual is closable the same way and was queued rather than closed: ex11 (EX Delta Species) holo. Its recorded blocker is verbatim "the task-data card list is missing so I cannot emit cardId selectors", and the committed catalog shard now supplies it — data/catalog/sets/ex11.json names exactly three Pokémon-ex (ex11-108..110, Flareon/Jolteon/Vaporeon ex) and three Gold Stars (ex11-111..113, Groudon/Kyogre/Metagross Star) among its holo-tier cards, matching the source claim's own enumeration ("Gold Stars, Pokemon-ex").

Why not close it here: the closure is an assignments edit, and data/foil-verification-map.json and data/foil-pattern-cards.json — the two artifacts the queue's leverage table, empty-pool diagnosis and every impact number are built from — are bake-catalog.mts outputs that need Postgres. This machine has no database. Committing the edit would have shipped an assignments file inconsistent with the committed bake, in the same change that introduced a surface whose contract is that every card traces to its data source. The resolver digest is measurable without a database (tools/parity/resolver-receipt.mjs), but a digest that moved with a stale verification map beside it is a worse artifact than an open residual.

Implications:

  • The ex11 card is in the queue as residual:ex-holos:ex11:holo, research skill, carrying the needs-a-bake guard — which is why that guard exists. Every residual card carries it.
  • Whoever closes it should expect the guesses for those six cards to move from mirror to cosmos, and the digest to change by exactly the printings those six cardIds carry across their holo-tier variants — 13 in the current catalog (ex11-108 ×2, ex11-109 ×4, ex11-110 ×4, ex11-111..113 ×1 each) — and nothing else. ex11-114 Azumarill is a secret rare the cited claim's enumeration does not cover, so it stays under the mirror row and the residual closes PARTIALLY, the same shape ex13's resolved note already uses.
  • The other 42 open residuals were checked and are blocked by something the catalog cannot supply: a source that names cards (ex14's three Holo Rare Masters sit in unparsed image captions), a rarity split the catalog does not carry, or the absence of a vocabulary slug entirely (dc1, sm3.5) — which no amount of catalog data closes.

2026-09-06 — Correction: Azumarill's #114/113 is a secret rare by numbering, not by the catalog's rarity field

Decided by: Claude Fable 5 on behalf of @cheyras

Correction: The entry directly above ("The ex13/ex16 residual closure…") called ex11-114 Azumarill "a secret rare" in the same sentence that cites the catalog rarity field — but data/catalog/sets/ex11.json records that field as "Rare", not "Secret Rare". The card is the set's secret rare by its number (#114/113, one past the set's 113-card printed count — the standard convention this catalog does not encode as a distinct rarity string), and the sentence should have said so instead of stating the catalog agrees.

Why: DECISIONS.md is append-only, including for same-week entries — a correction is appended rather than the original being edited, per AGENTS.md.

Implications: None to the ex11 residual's PARTIAL-closure boundary or to any code — this only corrects how the reason Azumarill sits outside it was described.

2026-09-06 — The live-tilt guard is keyed on ask, not stillFrameBlind; two stale doc claims corrected at their own sites; the uncanoned citation now points at a claim, not its fix

Decided by: Claude Fable 5 on behalf of @cheyras

Decision (three fixes, one commit):

  1. tools/task-queue/build.ts's verdict guard was keyed on the wrong field. stillFrameBlind ? ['live-tilt-not-glsl'] : [] put the full "this is a LIVE-TILT verdict, not another GLSL round" guard on energy-symbols, whose ask is glsl (it needs the 9-icon atlas) even though it is also still-frame-blind (the motion half of its nay is pixel-refuted). The card read "GLSL · hours" directly above a guard telling the reader not to do GLSL work — a contradiction on the structural pair the datum already distinguishes (ask vs. stillFrameBlind). The guard is now keyed on ask: the full guard applies only when ask === 'live-tilt'; a row whose ask is glsl but is still still-frame-blind gets a new, softer guard, motion-half-needs-tilt, saying the motion half needs a separate live-tilt verdict rather than telling the reader to skip the GLSL ask.
  2. The uncanoned reconciliation row cited the wrong site. claimedAt pointed at docs/HOSTED-EDITOR.md:195-200, which is the CORRECTION ("The number is 12, not 13"), not a place that still claims 13. Nothing in docs/ still asserts 13 as live; the only surviving mention is historical, in this file (DECISIONS.md:2062-2065, the 2026-09-06 task-queue entry restating "13 canon-less is 12"). claimedAt now cites that entry and says historical record explicitly, so a reader is not sent to the fix when looking for the claim.
  3. Two doc counts the queue reconciles itself against were fixed at their claim sites, and the reconciliation datum updated to match. docs/VERIFICATION.md:73 and docs/SHADER-CONTRACT.md:295 each gained a same-line, dated correction (no new lines inserted, so every downstream docs/VERIFICATION.md:NNN and docs/SHADER-CONTRACT.md:NNN citation in data/verification-verdicts.json and elsewhere still points at the right line): the approximated-type count is 1 (big-glitter), not 5, and the standing-nay count is 5 (ace-spec, energy-symbols, pokeball-hologram, prismatic-pokeball, radiant), not 4. CLAIMED_APPROXIMATIONS and CLAIMED_STANDING_NAYS in build.ts now hold the corrected values, so both reconciliation rows read agrees: true and no longer appear in --check's FINDING output. uncanoned is the one row that is NOT fixed — it never will be, because it is not a claim that drifted, it is subtask 5's arithmetic bug (45 − 32 counts none, which has no canon by definition) preserved on purpose as a permanent record.

Why: A guard that contradicts the ask sitting right above it teaches a contributor the wrong lesson on the first card they read. A claimedAt that cites the fix instead of the claim sends a reader hunting in the wrong direction. And a reconciliation mechanism whose whole point is "say so in the same commit" when a doc gets fixed cannot itself go on reporting a fixed doc as a finding — build.test.ts says exactly this in its own assertion message, and until now, the code did not.

Implications:

  • tools/task-queue/build.ts gained one GUARDS entry (motion-half-needs-tilt) and reworded the two CLAIMED_* constants and their reconciliation claim/note text to state they were corrected at their own sites, with the date.
  • tools/task-queue/build.test.ts updated: the energy-symbols fixture now carries stillFrameBlind: true (matching the real corpus) so the guard split is exercised in the synthetic corpus, not only the real one; the synthetic reconciliation test's expected claim text moved from "4 standing nays" to "5" (the constant is a doc claim, not corpus-derived, so it does not vary between the synthetic and real corpus); and the real-corpus "three stale doc counts" test is now "two fixed, one permanent" and asserts the uncanoned row's claimedAt no longer points at the correction text.
  • data/task-queue.json regenerated; --check passes with exactly one FINDING (uncanoned).
  • No environment variables were added, so DEPLOYMENT.md is unchanged.

2026-09-07 — The reference clip returns as an embed, and the citations become a datum

Decided by: Claude Fable 5 on behalf of @cheyras (Project Holo subtask 12)

Decision: The canon lab's reference pane — an empty slot since subtask 2 removed the committed clip.webm + 8 keyframes per pattern — is filled with a click-to-load, manually looped YouTube embed of the same seconds of the same source video. The citations that make it possible are promoted out of prose into packages/patterns/src/reference-clips.json, generated from reference/<slug>/notes.md by tools/build-reference-clips.mts and gated by a CI --check. Five decisions inside that, each taken deliberately:

  1. The datum lives in packages/patterns, not data/. It is imported by the bundle, so it needs no route, no fetch and no bake step — the pane has no API at all, and /reference is gone from api.ts along with its empty-index stub. It is MIT under the packages/** glob rather than CC0: these are citations of third-party video, not measurements of a printing, which is the same treatment reference/** already gives MANIFEST.json. Shape is { videoId, chapter, clipStart, clipEnd } per dir plus a source table carrying each video's title and creator for the attribution line.

  2. The loop is driven, not declared. loop=1 needs playlist=<id>, has limited support in IFrame embeds, and with a start/end pair restarts the WHOLE video rather than the segment — 3.5 seconds of reference followed by a 26-minute intro, forever. start/end are also integer-only and the corpus is not (radiant-collection-dots is 1257.5s–1261.0s). So apps/editor/src/reference/clipLoop.ts polls getCurrentTime() and seekTo()s the fractional start when playback passes the end.

  3. youtube-nocookie.com, and it stays that way structurally. The IFrame API offers a host option for this, but that leaves the domain to a config key somebody can drop. The pane renders its OWN iframe at the nocookie url and hands the element to new YT.Player(element) to adopt — the API reads the origin off the element's src, so the domain is a property of markup this repository controls. Verified end to end in the E2E run. The API script itself has no nocookie copy and does come from www.youtube.com; that is stated in ytApi.ts rather than glossed.

  4. Click-to-load, with a LOCAL placeholder. No iframe exists until somebody presses play. The obvious placeholder — the video's own thumbnail from i.ytimg.com — was rejected: it is the same third-party request on page load wearing a different hostname. The placeholder is our own markup carrying the datum's title, chapter and bounds as text.

  5. No CSP change was needed, and that is a finding rather than a non-event. There is no Content-Security-Policy anywhere in this deployment; the security headers in tools/build-functions.mts are x-content-type-options, referrer-policy and x-frame-options: DENY (which governs this site being framed, not this site framing YouTube). Nothing blocks the iframe. A future CSP must carry frame-src https://www.youtube-nocookie.com and script-src https://www.youtube.com, and there is a note at that site saying so. No headers and no environment variables changed, so DEPLOYMENT.md is unchanged.

Why: Removing the vendored clips was right — that footage is cut from other people's videos and foilkit cites rather than vendors (AGENTS.md F2) — but it left a contributor tuning a shader against a memory of the reference. Embedding from the source restores the comparison without a byte of third-party media in the repository, and it sends the creator real views, which is the right way round rather than a consolation. Driving the loop by hand is the only way to show the four seconds the notes actually cite.

Implications:

  • TWO CONSUMERS, TWO PATHS, AND THEY CANNOT BE UNIFIED. The embed and the local frames now start from the same three numbers in the same notes.md, and the next reader will try to make one serve the other. It cannot be done in the direction that matters: a YouTube iframe is cross-origin and no browser API returns a pixel from it. The embed is for human eyes; the Gemini articulation passes (reference/pipeline/gemini_vision.py) and the frame-diff harness read local frames that reference/fetch-reference.sh writes into gitignored reference-media/. Deleting either path does not make the other cover its work. The full argument is in the header of tools/reference-clips/notes.ts, pointed at from the datum's $doc, from reference-clips.ts and from fetch-reference.sh.
  • reference/manifest.mjs is now a SECOND parser over the same prose, for fetch-reference.sh's plan. Two parsers over one hand-written format drift, so the builder re-reads MANIFEST.json and refuses to write if any video id, chapter or bound disagrees. CI runs it as --check. Note the difference from the corpus manifest and the task queue: this output is a source file, not a baked artifact, so no build step regenerates it and nothing else would ever notice it going stale.
  • A heading that says clip: followed by something unreadable is refused by name rather than degrading to "no clip recorded" — which would put the pane's honest empty state in front of a contributor as a lie about a clip that exists.
  • The counts, measured rather than assumed. 44 pattern dirs, 5 source videos, 43 with a clip range. The one without is _interlude-what-is-a-holo, the physics segment, from which nothing was ever cut — and no pattern maps to it. Of the 45 entries in PATTERNS, 44 get an embed and exactly one does not: none, the plain-card baseline, which has no foil to film. (reverse-sheet borrows pokeball-masterball's footage, and the pane says so.) The subtask brief anticipated "13+ patterns may lack one"; that number is the count of canon-less patterns from docs/HOSTED-EDITOR.md §3, a different quantity entirely.
  • The pane is on both surfaces: the canon lab leads with it, card adjust carries it behind a closed <details>. Reuse was one import, so "CanonLab only" would have been a decision made by laziness rather than taken.
  • Attribution is a credit and a link, and claims no permission. Contacting the creator is the manager's item, not this one, and a line reading "linked with permission" would be a false statement about a real person on a public site.
  • Suites: 538 unit (was 506 — 19 parser, 13 loop controller), 81 editor E2E (was 58), 30 stage acceptance (unchanged). No CI job was added; one step was, next to the two existing --check steps.
  • Verified against the live API, not only against a mock (AGENTS.md F7). The built site was served locally and driven with a real network: before the click, zero requests to any Google host; after it, the real iframe_api script adopted our own youtube-nocookie.com iframe and fired onReady — which is the confirmation that the adopt-an-existing-iframe route works with the nocookie domain, and the reason the host option is not used. getCurrentTime() sampled every 500ms for 20s gave 40/40 samples inside 45.6s–49.17s with four clean wraps (48.72 → 45.63, 49.17 → 45.93, 49.09 → 45.85, 48.98 → 45.60) on cosmos, whose recorded bounds are 45.6s–49.1s. The loop holds the segment on the real player.
  • One thing that cost an hour and is worth writing down: the real YT.Player attaches its methods after construction. An instrumentation wrapper that captured inst.seekTo at construction time captured undefined and installed a permanent no-op — the loop then reported five successful seeks while playback ran away to 62s. Nothing in the shipped code does this; the harness did. If you go to instrument this player, observe it, do not replace it.

2026-09-07 — The reverse-holo ink design becomes a first-class tier

Decided by: Claude Fable 5 on behalf of @cheyras (Project Holo, subtask 13)

Decision: A reverse holo is modelled as TWO orthogonal layers rather than one. The FOIL — embossed micro-texture in aluminium — stays a procedural GLSL recipe in packages/patterns, which is correct because it is physics. The DESIGN — opaque ink printed over that sheet, blocking it — becomes DATA: data/ink-designs.json + data/ink-tiles/, resolved per printing by a parallel resolveInk on (scope, type, variantKind) + rarity, resolving card > subset > set > era, and entering the composite as coverage (m *= 1 − uInkStrength·inkCoverage(uv)). Full write-up: docs/INK-DESIGN.md.

Why: Procedural will never converge on the design because there is nothing to converge to. Sceptile and Magneton, and a dozen other reverses that look nothing alike, sit on the same vertical-sheen sheet. Tuning a recipe toward that set approaches the average of a set with no average, which is why "patterned reverse holos look really bad" is a category error and not a tuning gap. The resolver already half-knew this and discarded the knowledge: both tiers carry a penaltyOf demoting mirror rows on reverses because the corpus records them as ink-design evidence, not foil evidence. This tier is where that evidence goes instead of the floor.

The commissioned bug was checked first, and REFUTED — with numbers. The hypothesis was that reverse-sheet draws its ring+dot grid over a scan that already contains the printed design. Method: autocorrelation of high-passed luma over the sheet band, lags 4–80 px, n = 7 TCGdex high.png scans at 600×825. Instrument validated first on a positive control — the same scan with reverse-sheet's own lattice stamped in at uP0 = 11 — which returned lag 54 px at SNR 7.85, the predicted 600/11 = 54.5. Every real scan returned SNR 1.41–1.88 with peaks at 7–14 px, the adjacency length of the high-pass residual. Two of the seven (basep-33, pl1-SH4) are cards with no non-reverse printing at all, so their scan can only be of a reverse, and they read the same as the rest. Catalog scans do not carry a printed reverse design; they are the normal printing, per the TCGdex convention that one image serves every variant (sv08.5-040 serves six).

Three things survived the refutation and are why the tier shipped anyway:

  • The real defect is worse than double-drawing. The design is drawn from nothing at all — a lattice at a density nobody measured, at uniform spacing over the whole sheet — and then inkGlyph/inkDetail, which read local contrast, punch holes in it wherever the printed text happens to fall. That is misregistration, just not the predicted kind.
  • The double-draw hazard was real and completely unguarded. Nothing consulted shows. The first image that does carry a reverse printing — subtask 14's community captures, a re-shoot, one of the 29 reverse-only cards getting a better scan — would have been drawn on twice with nothing to stop it. uInkDraw is that guard, and it is why the guard shipped even though the bug did not reproduce.
  • The measurement is itself the answer to what shows: unknown should do.

shows: unknown DRAWS, and the assumption is written down. frames.json reads unknown on all 24 records and its own $doc says that is the finding, not a gap — derived, never claimed — and sets the rule: only a MEASURED reverse may suppress an overlay. So the default is normal, justified by the n = 7 measurement above and by the TCGdex one-image-per-card convention, and stated as an assumption in data/ink-designs.json's frameShows block. The per-frame override lives there rather than in frames.json, which is generated and must not be hand-edited; it is a human ratchet (F4), and it is empty today, honestly — nothing has been measured to reverse, and inventing an entry would be exactly the claim frames.json refuses to make.

NO composite-contract bump, and it is proven rather than argued. The contract versions the law that turns a stored uniform snapshot into pixels, and no stored snapshot changed meaning: every ink uniform is STRUCTURAL (surface-owned, never in a canon file), uInkOn defaults to 0, and every added instruction sits inside its branch. tools/parity rendered all 45 recipes on the blank base before and after: 45/45 byte-identical, against a control pair that was itself 45/45. No canon AE recheck was owed, and none was run.

The 3b handoff is the keying evidence, and it is cited on the rows. holo-archive/3b-pairs/delta-classes.md measured variantKind — not scope — as the discriminating term (the Xerneas pair is holo-against-reverse, both foiled, and the tag treatment tracked the reverse printing), and era-research.md supplied the three delta classes and the rarity gate. tools/rectifier/THRESHOLDS.md records why the numeric classifier cannot be trusted on photographs (CHANGED_PIXEL_DELTA = 24 calls 40.8% of a visually-black diff "changed"), so the classes ride along on each row as a cited delta rather than as something this tier recomputes.

Implications:

  • Four tiles shipped, six marks queued. Shipped: dot-grid, ring-dot, pinstripe-diagonal, crosshatch — generic lattice geometry, uncopyrightable, every coordinate a round percentage of the cell. Queued with the slot EMPTY: pokeball, masterball, specialty-balls, plasma-shield, energy-symbols, ex-set-logos. A traced Poké Ball is TPCi's design whoever ran the tracer, and a CC0 dedication over it would be worth nothing (F2). A queued slot leaves uInkOn 0, so those printings render exactly what they render today — the count going to zero is not the goal; every queued mark carrying its caution is.
  • data/ stays CC0 and our own tiles belong in it. Measured placement numbers are facts; the tile geometry is ours outright, so we have the standing to dedicate it. Covered by the existing data/** glob with a notice file (data/ink-tiles/INK-TILES-NOTICE.md, five sections, rejection trail included). ink-index.json joined the CC0 override list beside the other two derived resolver tables.
  • The period is measured and WEAK, and says so. across = 11.7 comes from one x-peak at 43 canonical px on one of 3b's four rectified pairs, at SNR 2.1, on pairs 3b itself records as misregistered. Recorded with n, snr, confidence: low and a caveat; the two unmeasured tiles carry n: 0 and confidence: null rather than a number with no provenance (F5). The slider surface exists so a human can correct it, and that correction is a decision this file may never overwrite.
  • ex-set-logos is recorded but NOT expressible. A set-name logo stamped bottom-right inside the art box is a single positioned stamp, not a lattice. It is queued so it is not lost; the second positioned layer is separate work.
  • plasma-shield is queued WITHOUT a row, because the catalog declares one reverse kind for bw8/9/10 and carries no Team Plasma flag. A set-wide row would be wrong for most of the set. The gap is visible; the wrong answer is not.
  • The task queue has a seventh source, a new art skill and a new originals-only guard. 108 tasks → 114. A queued tile is sized only when EVERY row queuing it can be counted — one uncountable row makes the number a lower bound, and a lower bound presented as an impact ranks the card below work it outweighs.
  • The resolver got its first tests, covering the ink axis only. Pinning the foil axis retroactively in the same commit would make a future regression ambiguous about which axis moved; resolver-receipt.mjs already digests 10,312 foil probes.
  • The first render of the layer was WRONG, and only looking at it found that (F7). Every test passed and parity was 45/45 — because the bug lived entirely on the SCAN path, which the blank-base harness does not exercise. The design painted straight across the ART WINDOW: sheet scope is the era rect INVERTED, so the mask is 0 over the illustration, and ink printed on a foil sheet cannot exist where there is no sheet. Fixed by scoping the coverage to the mask before the mask is reduced by it (inkCoverage(uv) · uInkStrength · m). Measured after the fix on a real SV reverse scan, split by region: art window meanAE 0.091 (1.2% changed — the mask feather edge) against sheet meanAE 8.82 (43.5% changed). The mask term is now pinned by a test so it cannot come back silently. The lesson is reusable: a blank-base parity pass proves a no-op, and proves nothing at all about a layer that only exists on a scan.
  • A second thing only the render found: tools/parity/serve.mjs had no .svg MIME entry, so a tile 200'd as application/octet-stream and the <img> silently refused it — coverage 0, procedural fallback, nothing in the console. A present tile read exactly like an absent one. The editor's vite middleware already carried the entry; this server did not.
  • Render evidence, one real SV reverse scan (sv03.5-095), meanAE over the sheet region: plain scan → today's render 13.07; today's render → the tier on with nothing to draw 1.56 (that delta IS the procedural ring+dot guess disappearing, in isolation); today's render → the registered design 8.82. The art window moves 0.015–0.091 in every comparison, which is the mask edge.
  • Suites: 561 unit (was 538 — 6 core, 3 patterns, 14 resolver), 81 editor E2E, 30 stage acceptance, 82 function — all unchanged and all green. One CI step added next to the three existing --check steps.
  • Deviation, declared: the ink TILES were authored; a tile-authoring surface was not built. The editor gets the placement sliders and the state readout (design / queued / in-scan / none), which is what a human needs to correct a measurement. Drawing a new lattice cell is a file in data/ink-tiles/ plus a notice section, done in an editor — and the queue cards deliberately carry link: null rather than inventing a route so the card had a button.

2026-09-07 — The queue page must survive a datum newer than itself, and the ink tier must draw nothing where it knows nothing

Decided by: Claude Fable 5 on behalf of @cheyras (Project Holo, subtask 13, adversarial verification round)

Decision: Two rules, each one generalised from a defect the entry above shipped.

(1) A generated artifact fetched at runtime may contain ids this client has never heard of, and every lookup against one must degrade rather than throw. apps/editor/src/queue/taskQueue.ts widens its type/skill/estimate ids to admit any string, routes every label through a degrading helper, and builds its filter chips from the task list rather than from the build-time order so a new kind gets a control instead of having its cards hidden behind one that does not exist.

(2) The ink tier draws nothing where no row keys the printing. The era lookup's ?? 'modern-sv' fallback is gone; an unmapped series resolves to state: 'none'.

Why: The queue went WHITE in production. data/task-queue.json gained a seventh source with a new ink-tile type and an art skill; the SPA's union had neither; TYPE_LABEL[task.type] came back undefined; reading .what off it threw during render and React unmounted the tree. The editor's landing page — the surface this whole subtask exists to fill — showed nothing at all, while every test in this repository passed and the E2E reported 81 green assertions.

It passed because the FIXTURE could not size an ink-tile card: the registry keys its queued rows on real set ids (sv08.5 + reverse-foil-pokeball) that a synthetic catalog does not carry, so all six baked as impact: null, sorted to the bottom, and landed at positions 60–65 — past the 20-card first screen, never rendered. Production ranks two of them at 10 and 11. CI was watching the exact bug and could not see it.

The second rule has the same shape one layer down. ERA_BY_SERIES[slug] ?? 'modern-sv' gave every series era-layouts.json does not cover — thirteen of the catalog's twenty-one, 6,963 of 13,165 reverse printings, 52.9% — the Scarlet & Violet dot grid at strength 0.8, on no evidence at all. That is the drawn-from-nothing defect the entry above is about, reborn in the tier that was supposed to end it.

Implications:

  • The fixture now carries the ink registry's real coordinates, four sets across two overlap series, on the same F2 split base1 already uses: real ids, invented names. Its ink cards rank 2, 4 and 5 — inside the first screen, as production's are. CI must see the surface production sees, and a fixture that ranks differently from the real artifact is not a fixture of it.
  • And the E2E presses "Show the other N", asserting one card per task in the artifact, a non-empty badge and a skill line on each, a chip for the new skill, and NO pageerror — a React unmount is not a console error, which is the other reason the run stayed green. That half holds regardless of ranking. 81 → 93 assertions.
  • Own-property lookups, not ?? fallback. SKILL_LABEL['toString'] inherits a function off Object.prototype and React throws on a function child, so the obvious guard would have restored the white screen for one adversarial slug. The tests name ids no builder will ever emit; a test written against the next real id only pins the last mistake.
  • A null tile now means three things and says which. queued (a trademark we may not trace), no-ink (a recorded decision that none is wanted), none (nothing keys this printing). Legendary Collection returned state: 'queued', queued: null — a shape InkDesignRef's own doc forbids — and so spent a release announcing itself as work nobody owed.
  • The tier ladder is strict. Scope weights ×100, so no accumulation of per-axis bonuses can lift an era row into a tie with a bare set row and hand the decision to confidence. Specificity and evidence quality are different claims. Latent, unreachable from today's registry, and pinned by scoring the pair directly rather than by waiting for a row that would produce it.
  • The rarity gate matches a whole rarity. ["rare"] as a substring was true of "Ultra Rare", "Secret Rare", "Rare Holo LV.X" and eleven more strings this catalog carries. Spelling variants are listed explicitly — a list is reviewable, a fuzzy match is not.
  • Two shipped tiles are reachable from no row, which is legitimate and is now DECLARED (unkeyedTiles, with the reason each is unkeyed) and cross-checked against the rows in both directions. An unreachable asset that nothing explains gets deleted as dead weight or keyed to an era on a hunch.
  • pinstripe-diagonal.svg did not tile. Its corner-wrap triangles had legs of 13 where the band's half-width is 26: mean |Δcoverage| 66/255 across the cell's own wrap — a grid line down every card it was used on, in a file whose own desc said "tiles seamlessly". Every tile is measured now (tools/ink-tile-seam.mjs, an analytic sampler over the SVG subset the tiles use, no rasteriser and no dependency), only 0 passes, the number is committed into the index, and a tile the sampler cannot read is a hard failure rather than a silent zero.
  • uInkOn false for a null tile is asserted where it is decided. The invariant spans two packages and the stage CANNOT enforce it — on: true with no texture is also legal there and means in-scan, the opposite thing — so the assertion lives in the resolver, is driven off the index so tomorrow's row is covered, and stage.ts carries a comment saying why it does not second-guess the answer it is handed.
  • CI's test-count comment is deleted rather than updated. It said 506 and the suite was 572. A number in a comment nobody can fail on reads as a commitment while being a fossil.
  • Suites: 572 unit (was 561 — 5 editor queue, 6 resolver), 93 editor E2E (was 81), 30 stage acceptance, 82 function. Two new --check failure modes on the existing ink-index gate: the tile seam, and tile ↔ row in both directions.

Correction to the entry above, and a re-measurement that did not find one. Its "Suites: 561 unit (was 538 …)" line was challenged as reading 533. It does not: the suite was re-run at 54a3b9c, the commit immediately before this subtask's first, and measured 538, with the delta 6 core + 3 patterns + 14 resolver = 23 exactly accounting for 561. tools/task-queue/build.test.ts held 20 tests before and after and contributed none of it. The line stands as written; this note records that it was checked rather than leaving the challenge standing against it.

Correction to the refutation's strength claim, which does not move the verdict. The entry above reports "every real scan returned SNR 1.41–1.88" as a flat range, which reads as though the instrument had uniform sensitivity across the seven. It does not: an adversarial re-measurement of that harness (2026-09-07; reported, not re-run here) puts the per-scan detection floor as low as roughly 20% stamp amplitude on basep-33 and higher on others, and notes that the palette/Adam7 encoding of some TCGdex PNGs interacts with the high-pass residual. The conclusion is unaffected — the positive control returned SNR 7.85 at the predicted period and every real scan returned peaks at the high-pass adjacency length instead — but the per-scan SNR figures are a property of scan and encoding as much as of the printing, and should not be read as "how much design each scan carries".

2026-09-07 — The path language gains a cubic, because a pen tool is cubics

Decided by: Chey Rasmussen, implemented by Claude Opus 4.6

Decision: Prim becomes LinePrim | ArcPrim | CubicPrim. The stored vector language keeps lines and arcs exactly as they were and adds { k: 'cubic', c1, c2, to }, with control points in the same fractional, y-down space as every other coordinate.

Why: #17 asks for a pen tool that authors vector masks, and specifies both "bezier handles are a click-drag gesture" and "store as VPath primitives" -- which the language could not do, because it had no bezier. The entry that established the two-primitive language argued a bezier is "more general and less checkable — an arc has a radius you can read and argue with". That argument was about a FITTER's output, where a curve nobody chose is harder to audit than a radius somebody can measure. It does not carry to a curve a human placed on purpose: the reason #17 wants vector at all is that "a vector diff is readable text in a pull request", and six numbers in a diff satisfy that as well as three do. The alternative — beziers while editing, arcs on save — reintroduces exactly the lossy round trip the task exists to remove.

Implications:

  • The pen emits a plain LinePrim whenever both adjacent handles are retracted, so straight segments stay in the old language and the committed corpus is not churned into curves that merely look straight. data/vector-templates.json is untouched and still fits and rasterises bit-identically.
  • The trap this created, and how it is now closed. Every consumer that branched on primitive kind did so with a ternary — pr.k === 'line' ? .. : .. — whose else-branch silently means "arc". A third kind makes each of those a wrong answer that TypeScript cannot see, because a ternary's else-branch was never asked to be exhaustive. All four sites are now switch statements with a never-typed default, so a FOURTH primitive is a compile error rather than a silent misrender. toPx and norm were doing the same job and were collapsed into one exported mapPathCoords, because "scaled to, forgot c1/c2" had two places to hide; a test rasterises the bug's own output alongside the right one and asserts they disagree.
  • Cubic flattening uses the convex-hull bound rather than the textbook 3/4 x max(d1,d2) flatness test. The 3/4 factor is only valid when both handles project onto the chord, and a pen produces the other case constantly -- drag a handle back past its anchor and the cheap test calls a cusp flat.
  • pointType (smooth vs corner) is deliberately NOT stored yet. It is Illustrator's stored flag, not a derived property, and a corner may legitimately carry two collinear handles; the plan is an optional one-character field per primitive when the editor persists pen masks, so that a corner does not silently become a smooth point the first time someone drags its handle.

2026-09-07 — The editor stops hand-porting forge, via a guarded browser subpath

Decided by: Chey Rasmussen, implemented by Claude Opus 4.6

Decision: @foilkit/forge gains a ./geometry subpath exporting only the modules that are free of node builtins — the vector language, the pen geometry and engine, the shared rasteriser, the contour tracer. tools/check-geometry-browser-safe.mjs walks the import graph from it on every push and fails if any reachable module gains a value import of a node: builtin. A statement-level import type stays legal, which is what keeps edge-trace -> png.ts allowed.

Why: the barrel pulls node:fs, node:zlib and node:child_process, so the editor could not import forge at all. The existing answer was to hand-port the functions it needed into apps/editor/src/staging/provisionalDiff.ts and keep a byte-parity test in step. That is a reasonable answer for two functions and a terrible one for a pen tool, which needs the snapping and the rasteriser to be the same code — a second implementation of a rasteriser is a second answer to "where exactly is this mask's edge", and the whole point of the corpus is that there is one.

Implications:

  • The pen's live preview rasterises through rasterizePolygons, the same function templates and masks already go through, so what the shader shows while drawing is what gets committed.
  • The guard is proven non-vacuous rather than assumed: injecting a node:fs import three modules down fails it, and narrowing that same import to import type passes it.
  • README.md's line saying every forge import is a node builtin was the exact sentence that would send the next person back to hand-porting. It now names the subpath and the guard.

2026-09-07 — The pen tool: an Illustrator clone, and what "clone" was allowed to mean

Decided by: Chey Rasmussen, implemented by Claude Opus 4.6

Decision: #17's pen tool is built to Adobe Illustrator's interaction model deliberately and specifically — same gestures, same modifier semantics, same keyboard bindings — rather than to a reasonable-looking approximation. The behaviour was researched into a 126-item conformance checklist first, and the checklist is the acceptance criteria: engine tests are named after the items they prove.

Why: the person who has to use this tool lives in Illustrator. A pen that is 90% right is worse than one that is obviously different, because the missing 10% is discovered one muscle-memory failure at a time, mid-task, forever. Deciding fidelity by argument was not an option either — most of what makes the tool feel right is undocumented folklore — so it was verified against Adobe's own reference material, with the parts that could not be verified flagged rather than guessed.

Implications:

  • The engine is headless. pen-engine.ts has no DOM, no React, no canvas: it is reduce(state, input, cfg) over a plain serialisable state, driven in tests by synthetic events. "Feels like Illustrator" is a claim about behaviour, and behaviour that can only be exercised through a browser barely gets exercised. The React surface is deliberately dumb — the cursor comes from cursorFor, the handle stubs from visibleHandles, the bindings from one exported table.
  • The two smooth-point rules are kept apart, because collapsing them is the single most common way a bezier editor feels wrong: handles are created MIRRORED at placement, but on a later edit dragging one handle rotates the opposite to stay collinear while KEEPING ITS OWN LENGTH.
  • pointType is stored, never inferred. A corner may legitimately carry two collinear handles. The stored language had no room for the flag, so each primitive gained an optional t. It names the anchor a primitive LANDS ON, which means under reversePath every flag moves one slot — copying them across with their primitives yields identical shape, identical pixels, and permanently wrong editing behaviour. Both that and the mapPathCoords equivalent are tested.
  • Deliberate deviations, all of them flagged rather than silent: Illustrator's arrow-key nudge breaks the pen's connection to the active path, which its own users call a bug — ours does not, and the flag to restore it is tested in both positions. Illustrator's +/- select separate tools; we have no tool slot for them, so they act on the selection. And the host editor bound bare +/-/0 to zoom, so the pen claims +/- while it is active and hands them straight back when it is not.
  • We do not own Ctrl+0 / Ctrl+= / Ctrl+-. They are Illustrator's zoom bindings and also Chrome's, and a page cannot reliably take them. They are wired best-effort; the wheel and the ZoomHud remain the reliable path, and the comment says so rather than implying we won that fight.
  • No Adobe cursor art. F2 forbids tracing their glyphs, so cursorFor's ten states render as the nearest standard CSS cursor plus a data-pen-cursor attribute that the acceptance run asserts on. The states are semantically distinct and not yet visually distinct; adding art later is one table.

2026-09-07 — A path diff that does not describe its pixels is worse than a binary one

Decided by: Chey Rasmussen, implemented by Claude Opus 4.6

Decision: a submission carrying both a mask PNG and pen-authored paths is refused unless the server rasterises the paths and finds they agree with the pixels: IoU >= 0.98 and boundary p95 <= 2px, checked in functions/_lib/validate.ts and called from both the contribution route and the direct-write route. A raster-only save REMOVES any existing .paths.json rather than leaving it.

Why: the entire justification for storing vector is that a reviewer can read the change. A .paths.json that no longer describes the mask beside it is a confident, legible, wrong description — and it is worse than the binary blob it replaced, because a reviewer would believe it. This is F3 in its usual form: derived from the artifact, never taken from what the caller asserted.

Implications:

  • The two thresholds do different jobs and both are needed. IoU is the area backstop and must tolerate two honest rasterisers disagreeing along the antialiased rim, so its floor sits just outside a full pixel of systematic boundary offset. Boundary p95 is the locality measure, and it exists because IoU dilutes: dragging one anchor of a 2000px boundary costs a fraction of a percent of area, which a floor loose enough for antialiasing would never catch. Measured, not guessed: a 0.9px whole-boundary offset passes on IoU, one handle moved 10px is refused on p95.
  • Stated limitation: a displacement confined to under ~5% of the boundary passes p95 by definition. The check proves the paths are a way to reach these pixels to within a pixel or two everywhere, not the only way.
  • The check rasterises through the same rasterizePolygons the editor previews with. A second rasteriser would put the two sides of the comparison a fraction of a pixel apart everywhere, and the tolerance would then be covering the disagreement between rasterisers rather than the one that matters.
  • The frame migration removes vectors rather than rescaling them: 490x674 -> 504x704 is anisotropic, and an arc under anisotropic scale is an ellipse this language cannot express. The archive keeps a verbatim copy, so a revert restores.

2026-09-08 — The pen learns to catch a printed edge, and to say when it cannot

Decided by: Chey Rasmussen, implemented by Claude Opus 5

Decision: pen-engine has always accepted an injected SnapFn and policed it with maxSnapMovePx, and nothing was ever injected — DEFAULT_PEN_CONFIG.snap shipped null, so the pen could not catch the printed edge a person was visibly tracing, and constructionAngles was declared, defaulted, asserted in a test and read by no code. Three things close that:

  • forge/pen-snap.ts, a pure provider built on the detectors that already exist. It prepares edge-trace's colour structure tensor ONCE per scan in canonical mask space and then answers point queries in microseconds, in the spec's priority order: an existing anchor a human placed, a printed-edge INTERSECTION, then the nearest point on a printed edge.
  • A refusal channel. SnapFn may now return { refused } as well as a proposal or null, and the reason lands in state.snapRefusal. Two comparable parallel ridges within line-snap's own ambiguityRatio of 0.85 mean the scan says "you are near some edges", not "you meant THAT edge", and the point does not move.
  • Ctrl+U (I.102) as the master switch, in the engine's binding table and in PenState, with a visible control on the surface that dispatches the same binding rather than a private code path. Edge snapping ships ON; constructionAngles — now actually read, at the two placement sites where Shift is read, with its own screen-px tolerance — ships OFF, as Illustrator's Construction Guides do.

Why: the acceptance criterion for the pen was "trace a rounded-rect foil window on a real scan, watch the segments snap to the printed edges, save it as a vector mask". Every clause of that was true except the middle one, and the middle one is the reason a pen beats a brush on a scan. The guardrail is not negotiable in the process: line-snap's test says an ambiguous band may nudge a line and never relocate it, and a pen that obeyed an unbounded snap callback would have been the hole in that.

Implications:

  • TWO INDEPENDENT LAYERS, and the test file says so. The provider refuses on the EVIDENCE ("two comparable ridges"); the engine refuses on the DISPLACEMENT ("3.5 screen px, over the 2px limit"). The engine is never told whether the provider feels confident, and pen-snap.test.ts drives a REAL provider aimed at a real edge too far away to prove the second layer holds without the first.
  • Measured, with its n (tools/measure-pen-snap.mts, synthetic cards whose edge coordinates are known exactly, because on a real scan the "true" edge is itself an estimate). Edges, n=360 queries per condition: 100% answered, residual mean 0.047px on a crisp step, and unchanged through a 3px Gaussian blur; with +/-6 noise, 0.299px. Corners, n=64: on a crisp step 52 answer corner with mean residual 0.435px, and by a 3px blur NONE do — every one degrades to the edge snap it can still see rather than to a corner it cannot. On a card with no edges at all, n=360: zero proposals, on flat grey and on flat grey with +/-14 noise.
  • Stated limitation. Edge snapping is the robust half; corner detection is the fragile half and vanishes as a corner rounds off. The blur sweep is a stand-in for scan quality and nothing more — it says nothing about halftone rosettes, JPEG ringing, or foil blowing out under a flash.
  • A degenerate scan gets a refusal, not a guess, and this is now visible in the acceptance run. The e2e's 64x88 stand-in has to be blown up nearly 8x to reach canonical space, and a bilinear 8x upscale turns a step edge into a 16px ramp with a comparable ridge at each shoulder — so the snapper refuses on it, out loud, and the run asserts that. Snapping itself is asserted against a 600x825 fixture, which is the resolution images.high actually serves. A test that asserted snapping against the small one would have been asserting that the snapper guesses.
  • Preparation is off the interaction path: ~70ms for the tensor at 504x704, once per card, behind an idle callback, and the pen is fully usable unsnapped until it lands. data-pen-snap-provider distinguishes "not wired yet" from "switched off", because a test that cannot tell those apart races the callback.
  • The refusal is shown, quietly. One dim line in a strip on the card face that never blocks a click and is gone on the next gesture. A user who cannot tell a deliberate refusal from a snapper that failed to notice learns to distrust the whole feature.

2026-09-22 — Opt-in tangent-space view direction

Decided by: Chey Rasmussen, implemented by Codex

Decision: Add a library-only opt-in that supplies surface-to-camera view direction in card-local tangent space while preserving the recipe ABI vec3 foilPattern(vec2 uv, vec2 tilt). The convention is +x along increasing UV x, +y along increasing UV y, and +z along the front normal. buildFoilShader(pattern) remains byte-for-byte unchanged; {viewDirection: true} adds uViewDirection and maps x/y through a bounded perspective divide before the existing recipes see it. FoilStage opts in per card through CardSettings.viewDirection and caches legacy and opted materials separately.

Why: Tangent-space view direction is established public prior art for stylized holofoil response (Cyanilux, “Holofoil Card Shader Breakdown”), and Three's documented matrix/normal conventions support deriving it from current transforms. This is an original implementation and a better geometric input than synthetic pointer tilt when a surface explicitly requests it.

Implications: The Three adapter computes a planar-center approximation after camera and mesh transforms are current. Perspective uses center-to-camera; orthographic uses its parallel view ray. The orthonormal frame preserves transformed UV x and the inverse-transpose front normal, including reflections; under hierarchy-induced shear, Gram-Schmidt chooses the bitangent sign that agrees with transformed UV y. Singular or non-finite frames use +z. Direct uniform writers remain responsible for finite normalized input. This does not provide per-fragment perspective parallax, does not retune any recipe or default, and makes no claim of physical or foil fidelity. The shader's bounded mapping covers zero, grazing, and backfacing finite vectors. Conceptual sources: https://www.cyanilux.com/tutorials/holofoil-card-shader-breakdown/ and https://threejs.org/docs/pages/Matrix3.html.


Related

  • Pre-History — the decisions made before this repository existed, as their commit messages
  • Home — the routing table for the repo/wiki split

Last updated by Codex on behalf of @cheyras — 2026-09-22

Clone this wiki locally