Some output must be a pure function of a key: the daily content every user compares, a generated
layout, a shuffle a share code reproduces, a replay. This skill makes that output identical on every
device, in every time zone and on next year's build, with no server: one seeded PRNG the app owns,
civil-date keys, content regenerated from (key, version) instead of stored, shipped generators frozen
behind versioned cutovers, and golden vectors computed by an independent oracle that two checkers and
the test suite verify.
Rules that must hold
- The key is an argument; seeded code never reads a clock. A generator takes its
DateKey, id or sequence number as a parameter; nothing it reaches callsDate.now,new Date,performance.nowor imports the clock service. Why: a clock-reading generator cannot be pinned, replayed from a bug report or run for tomorrow in a test. - A day is a civil-date key
'YYYY-MM-DD', computed with integers, from one day definition. Keys come fromclock.today()at the edge and go throughsrc/core/dates/date-key.ts; never an instant, never the UTC day next to a local-day comparison. Why: an instant makes every read zone-dependent, and two day definitions disagree for hours every day. - One entropy source:
src/core/rng/sfc32.ts, threaded as state. NoMath.random,crypto,expo-crypto,uuidor PRNG package on a seeded path; functions take a state and return{ value, state }. Why: a library PRNG promises no frozen sequence across its versions, and only exact integer operations (all sfc32 uses) give the same bits in V8 and in Hermes on iOS and Android. - A seed is a hash plus a frozen salt, mixed by
seedRngbefore any draw.seedRng(dailySeed(key, SALT))orseedRng(hashSeed(text) ^ SALT); neverdailySeed(...) % n. One salt per feature and mode, unique and recorded. Why: FNV seeds of neighbouring days differ by a small multiple of the FNV prime, so an unmixed seed walks the catalog in a visible step; shared salts make two features move together. - Seeded paths, and every file they import, use only exactly specified operations.
+ - * / %, bitwise,Math.sqrt/imul/floor/round/abs/min/max/PI; no**, no otherMath.*, noIntl,toLocale*orlocaleCompare; floats frozen to integers before they are stored or fingerprinted. Why: ECMA-262 leavessin,exp,powand**implementation-approximated (Hermes uses each platform's libm) and Hermes takes locale data from iOS and Android separately. - Draw order is part of the output. Each generator version documents its draws; inputs are sorted
by a stable key (never
localeCompare, never unordered SQL); the core modules never change. Why: one extra or reordered draw rewrites every output after it. - Regenerate, never store. Persist the key, the
generatorVersionthat ran and the user's outcome; regenerate the content withgenerate(key, storedVersion). Why: a stored copy is a second source of truth that the next version contradicts, and history becomes un-migratable. - A shipped generator is frozen; a change is a new version. A date-keyed generator serves it from
a future cutover date; a text-keyed one (a share code) carries the version in the key, and callers
pass it. Append the version to the code table and the contract, keep every old version callable,
stamp the version in the output; a committed cutover, salt or generator never moves or disappears
(removing a whole generator takes a migration, the developer's approval and a
"retired"entry). Salt, catalog, count and draw-order changes are all versions. Why: once users saw a key's output, that output is what the key means. - Every generator is pinned by golden vectors. A committed
<feature>.vectors.jsonof(key, version) → fingerprint + summaryrows, asserted exactly by a Jest test, covering every version; for date-keyed generators also both sides of each cutover, a year-end/new-year pair and a leap day. Why: a fingerprint catches any change and a summary line shows a reviewer what changed. - Expected values come from an independent oracle. The oracle is written from the spec in another
language or style (BigInt TypeScript, Python), imports nothing from
src/, directly or through a helper, and carries its own copy of the data. Why: values generated by the implementation under test enshrine its bugs. - Rows are filled by a reviewed hand-run script; committed rows are frozen; CI verifies and never blesses. The update script fills only missing rows and refuses to change a filled one; the checker fails any committed row that changed or vanished, however it was refilled; no CI job, hook or gate script runs the script. Why: a refilled row erases the record of what users saw, and a gate that rewrites what it checks verifies nothing.
- The core is a compatibility contract pinned by literals.
sfc32.ts,seeded-draws.ts,fingerprint.ts,date-key.tsanddaily-seed.tskeep the golden values their tests pin as literals computed by the oracle. Why: a change there changes every seeded output on every installed copy, and a golden computed by the module under test can never fail. - Generators are pure and repeatable. A generator loads in plain Node (no React Native, Expo,
stores or services), is synchronous, keeps no module state, and returns identical output for the
same
(key, version)whatever ran before. Why: purity is what lets tests, oracles and checkers run it headlessly, and hidden state makes the second call differ from the first.
Workflow
- Decide what is seeded. List every output a user may compare with another user or with their own past (daily content, shuffles, share codes, generated layouts). Read references/determinism-policy.md for what seeded code may use and why.
- Copy the core (once per app) from
templates/src/core/:rng/sfc32.ts,rng/seeded-draws.ts,rng/fingerprint.ts,dates/date-key.ts,dates/daily-seed.ts,dates/generator-versions.ts, each with its test. Never edit them; read references/seeded-rng-and-seeds.md and references/civil-date-keys.md for the API, the seeding and the goldens. If an app already has adate-key.tsfrom dates-streaks-and-statistics, keep one copy. - Write the generator from
templates/src/features/__FEATURE__/__FEATURE__-generator.ts(replace__FEATURE__kebab,__FEATURE_PASCAL__,__FEATURE_CONST__): pick a new random salt, freeze the catalog, document the draw order, exportgenerate(key, version?)andsummarize(output). Take "today" fromclock.today()in the caller (dates-streaks-and-statistics owns today). The template salt0x2f6b4c1d(795560989 in the contract), theitem-01 … item-12catalog in the generator and the oracle, the generator test's/^item-\d{2}$/id check and the vectors rows are template values: replace them all before the first release (step 6 refills the rows). A generator keyed by text (a share code) imitatesexamples/starter-pack-share-code/:"keyKind": "text", every versionfrom: null, and callers always pass the version the code carries. - Register it in
contracts/seeded-generators.json(template; keys in references/checkers.md): module, exports, salt in decimal, versions, vectors file, outcome file, regenerate command. An app with no generator keeps"generators": []. - Write the oracle from
templates/scripts/oracles/__FEATURE__-oracle.ts: reimplement the generator from its spec with BigInt or another language, no import fromsrc/, its own copy of the catalog and salt. Read references/golden-vectors.md. - Pin the vectors. The template rows belong to the template salt and catalog. Before the
generator first ships, delete each row's
fingerprintandsummary(keepkey,versionandnote) and set the contract'ssaltto the new salt in decimal. Add rows{ "key", "version", "note" }(first key, a historical key, year end and new year, leap day), runnode scripts/oracles/update-<feature>-vectors.tsfrom the repo root, and read the diff. A committed row is never refilled:check-golden-vectors.mjsfails it (rulevectors-frozen; the template's seven rows are the one exception). Keep the vectors test and the oracle test from the templates. - Store outcomes, not content. Persist
(key, generatorVersion, outcome)asexamples/daily-focus-outcomes/shows: the repository port and row mapping insrc/data/<feature>/, the replay insrc/features/<feature>/. persistence-and-migrations owns the table. When the table exists, point the contract'soutcome.fileat the file that declares the stored row. Read references/versioning-and-regeneration.md. - To change a shipped generator, follow "Shipping a new version, step by step" in
references/versioning-and-regeneration.md and imitate
examples/daily-focus-cutover/: new version, future cutover, new oracle branch, new rows only.check-golden-vectors.mjsfails a new cutover that starts today or earlier, and any edit of what is committed (gitHEAD): a moved or deleted cutover, a changed salt, a removed generator, a changed or deleted vectors row. In CI pass the base branch:--committed-ref origin/main. - Test:
npx jest src/core src/features/<feature> scripts/oracles --ci --selectProjects unitandnpm run typecheck. - Run the checks:
node ${CLAUDE_SKILL_DIR}/scripts/check-determinism.mjs .andnode ${CLAUDE_SKILL_DIR}/scripts/check-golden-vectors.mjs .(in a workspaces repo, once per app, with the app folder as the root); fix everyFAILline (file, rule, fix) and rerun until both printRESULT: PASS. - Commit vectors and contract changes with a
Gate-Change:trailer (git-commits-and-reporting) naming the generator, the version and why; a changed row is never committed.
Definition of done
- Every seeded output has a generator that takes its key as an argument and reads no clock,
ambient randomness or locale data;
clock.today()is read once at the edge. - The core files are the templates, unedited, with their tests pinning the oracle's goldens.
- No template value is left before the first release: own salt (in code, oracle and contract),
own catalog, own id check in the generator test, rows refilled by the oracle (the checker's
template values still in usenote no longer prints). - Each generator is registered with a unique frozen salt, its versions table, vectors file, outcome
file (or
null) and regenerate command. - Each vectors file was filled by the oracle (not the generator) and covers every version (date
keys: both sides of each cutover, a year-end/new-year pair, a leap day); no committed row changed
or vanished (
vectors-frozen; without git the checker prints afrozen checks:note instead). - Stored outcomes carry the key and
generatorVersion, never the generated content. - No CI job, hook or gate script runs a regenerate command.
- The core, generator, vectors and oracle tests pass;
npm run typecheckis clean. -
node ${CLAUDE_SKILL_DIR}/scripts/check-golden-vectors.mjs .printsRESULT: PASS(allowed while the outcome table does not exist yet:SKIP src/data/<feature>/<feature>-outcome-repository.ts [outcome-version] not yet created (persistence-and-migrations creates it); an app without generators printsNOT APPLICABLE: contracts/seeded-generators.json declares no seeded generators). -
node ${CLAUDE_SKILL_DIR}/scripts/check-determinism.mjs .printsRESULT: PASS
Anti-patterns
new Date().toISOString().slice(0, 10)inside a generator. The UTC day, read from a clock: not reproducible and wrong for hours every day. Take the key fromclock.today()as an argument.CATALOG[dailySeed(key, SALT) % CATALOG.length]. Neighbouring days step through the catalog by a fixed amount.drawOne(CATALOG, seedRng(dailySeed(key, SALT))).Math.random()for a harmless tie-break. One call and no two devices agree. Draw it from the threaded state.- Sorting with
localeComparebefore drawing. iOS, Android and Jest order differently. Compare ids with<. - Fixing a typo in a shipped catalog. Every past key that drew it changes. Ship version 2 with a future cutover.
- Storing the generated list next to the outcome. Two sources of truth. Store the version and regenerate.
- Pasting the generator's output into the vectors file, or
const GOLDEN = hashSeed(...)in a test. The expected value then agrees with every bug. Compute it with the oracle. - Deleting a shipped row's values and re-running the update script to make a red test green. That rewrites history users saw. Restore the version that made the row. Refilling is only for rows no released build has served, such as the template rows after you set your own salt.
npm run vectors:update(orpnpm vectors:update, or a script that calls it) inverify, CI, an EAS Workflow or a hook (simple-git-hooks,lint-staged, husky). The gate passes by construction. Run it by hand and review the diff.- Moving a committed cutover to "fix" a date, or editing a committed salt in code and contract together. Both still agree, so only the committed contract shows the rewrite. Append a version with a future date instead.
- Deleting or renaming a shipped generator's entry to get past a red check. Stored outcomes and
share codes still point at it. A rename is compared with the committed entry; a removal needs a
migration, the developer's approval and a
"retired"entry. - A share-code generator that defaults to its newest version. A version 1 code then opens as a version 2 pack. Pass the version the code carries.
- A helper in
src/utils/that a generator imports and that readsDate.now()or callsMath.exp.check-determinism.mjsfollows the generator's imports into it and names the chain; a package fromnode_modules(a libraryshuffle) is caught only by the runtime trap. Pass the value in, and use integer tables instead of inexact maths.
Files in this skill
| File | What it is | Read/run when |
|---|---|---|
| references/determinism-policy.md | Seeded paths, allowed operations and the spec/Hermes evidence, clocks, randomness, locale, ordering, floats, iOS/Android/Jest table, who enforces what, pitfalls | Workflow step 1, and when a lint or scan rule fires |
| references/seeded-rng-and-seeds.md | sfc32 and its API, hash + salt + mix, the unmixed-seed trap in numbers, salts, threading, seeded draws, per-index hashing, the UI thread, the goldens and the Python oracle that computed them | Workflow steps 2 and 3 |
| references/civil-date-keys.md | DateKey, its integer arithmetic, one day definition, routing by key, the daily seed, pinned facts and their oracle, device behaviour | Workflow steps 2 and 3 |
| references/golden-vectors.md | The vectors file, canonical JSON v1, coverage, the independent oracle, the fill-only update script, replacing the template rows before the first release, CI verifies, reading a diff, labels over metrics, a Python generator oracle | Workflow steps 5 and 6 |
| references/versioning-and-regeneration.md | What to store and where (the outcome layout), replay by version, what counts as a change, the cutover procedure, choosing the date under store releases and OTA, text keys and share codes, retiring a generator | Workflow steps 7 and 8 |
| references/checkers.md | Both checkers' rules, the contract format, the committed baseline (--committed-ref in CI, renames, "retired"), layout options, multi-package use, expected SKIP/NOT APPLICABLE lines and notes, troubleshooting |
Workflow steps 4 and 10 |
templates/src/core/rng/ |
sfc32.ts, seeded-draws.ts, fingerprint.ts and their tests (synced, canonical) |
Workflow step 2 |
templates/src/core/dates/ |
date-key.ts, daily-seed.ts, generator-versions.ts and their tests (synced, canonical) |
Workflow step 2 |
templates/src/features/__FEATURE__/ |
The generator scaffold, its test, its vectors file and the vectors test | Workflow steps 3 and 6 |
templates/scripts/oracles/ |
The BigInt oracle, its test (CI checks the committed rows) and the fill-only update script | Workflow steps 5 and 6 |
templates/contracts/seeded-generators.json |
The generator register both checkers read | Workflow step 4 |
examples/daily-focus-cutover/ |
The Tasks app's daily-focus generator after a version 2 cutover: generator, vectors, oracle, contract | Workflow step 8 |
examples/daily-focus-outcomes/ |
Storing (key, generatorVersion, outcome): the repository port, the row schema and mapping (src/data/), and the replay that regenerates what the user saw (src/features/) |
Workflow step 7 |
examples/starter-pack-share-code/ |
A text-keyed generator (share codes v2:7KQ2): versions without cutover dates, explicit version on every call, a Python oracle with verify and --fill modes, its vectors |
Workflow step 3, when the key is not a date |
| examples/reminder-jitter.ts | Stateless per-id randomness with hashU32 (reminder offsets) |
When each item needs its own stable random value |
scripts/check-determinism.mjs |
Runs the core against an independent implementation and the goldens; scans seeded paths and every local file they import | Workflow step 10, and at the end |
scripts/check-golden-vectors.mjs |
Runs every registered generator against its vectors with ambient APIs and inexact maths trapped; checks the register, what is committed (generators, salts, cutovers, vectors rows), coverage, oracle, test, outcome, no auto-bless | Workflow step 10, and at the end |
scripts/lib/determinism-spec.mjs |
The checkers' BigInt/Date.UTC implementation of the core, the golden table and the Math allow-list | When a golden, a core rule or the allow-list changes |
scripts/lib/module-graph.mjs |
The checkers' import reader: what a file loads (static, import(), require(), type-only), every local file a set of files reaches, and the enclosing workspaces repo of an app folder |
When a checker follows imports |
scripts/lib/app-modules.mjs |
Shared helper (synced; do not edit here): imports the app's TypeScript for the runtime checks | Never by hand |
scripts/lib/source-scan.mjs |
Shared helper (synced; do not edit here): the source scanner (masking, imports, worklet directive) | Never by hand |
scripts/check-lib.mjs |
Shared script library (synced; do not edit here) | Never by hand |
scripts/selftest.mjs |
Proves both checkers pass the templates and catch every planted bug | After changing a checker, template or example |
assets/shared.json |
Declares the synced files (check-lib, helpers, the canonical core) | When adding a shared file |
assets/template-placeholders.json |
The placeholder values the template verification uses | When adding a placeholder |
tests/bases/app/ |
The minimal app the fixtures lay the templates on | When the fixture base changes |
tests/fixtures/ |
Overlay suites: good/, one bad-* per rule, pass-* and error-* cases for both checkers |
When adding a checker rule |
Related skills
dates-streaks-and-statistics- reading today, local midnight, DST, streaks and statistics over date keys.architecture-and-boundaries- theClockPortand where pure core code lives.persistence-and-migrations- the outcome table and retiring a generator version.golden-and-snapshot-tests- data snapshots and pixel goldens;run-goldens-rebaselinere-blesses those.unit-and-component-tests- the Jest projects, fast-check and the pinnedTZ=UTC.typescript-and-lint-rules- theDETERMINISTICandPURElint blocks.value-objects-money-and-units- integers instead of floats for stored values.i18n-strings-and-catalogs-Intlformatting at the edge, never on a seeded path.git-commits-and-reporting- theGate-Change:trailer for vectors and contract changes.ota-updates- runtime versions and rollouts that decide when a cutover is safe.game-rules-engine,level-generation-and-solvers,realtime-game-loop- seeded games built on this core.