A composition can set a variable to a value outside that variable declared
enum options. The value silently falls back, the composition renders
something the author did not ask for, and no signal says a choice was
ignored.
Warns on both paths that resolve a variable, because they are separate. The
runtime guard covers a top-level composition. The compile guard covers a
sub-composition given instance values: those are baked into the variables
table at compile time and the scoped getVariables shim only reads that
table, so the runtime guard never runs there. That sub-comp case is the one
that motivated this, and it was the silent one.
Both call the same helper, so the message and the per-process dedupe set are
shared and an author sees one warning either way.
A warning rather than an error, for symmetry: the same defect must not carry
two severities depending on which mount path an author happened to use.
Escalation already has a home in lint, which a project can make blocking.
The check is split across four small helpers rather than one function:
parsing the declaration, naming the composition, reducing the option set to
comparable scalars, and deciding whether a value actually fell back. As one
function it audited at 21 cyclomatic and 25 cognitive.
Tests were mutation-checked. Five distinct breakages each killed a test,
including one that coerces the unknown value to the default instead of
warning, which would turn a diagnostic into a silent rewrite.
* fix(player): stop re-encoding the composition query
Every src the player sets goes through withShaderQueryParams, which parsed
the author's whole query with URLSearchParams and re-serialised it with
toString(). That is a form encoder: it writes a space as +, while callers
percent-encode and read back with decodeURIComponent. Those two codecs are
not inverses, so any space in any query value arrived corrupted.
It ran even when there was nothing to inject. With no shader attributes
both params are deleted, so the round-trip was pure loss, on every src,
for every consumer.
Append the two params to the raw query instead of re-serialising it. The
player now hands a composition its query back byte-identical.
Empirically space was the only casualty: plus, ampersand, equals, hash,
percent, question mark, quotes and non-ASCII all survived a URLSearchParams
round-trip. That is narrow, but a space in a headline or in SVG path data
is the common case, and invalid path data renders nothing at all.
Latent until now: no shipped consumer depended on query preservation, so
this surfaced only once compositions began carrying variable payloads.
* fix(cli): serve the runtime ahead of every author script
injectRuntime appended its script before </body>, so it landed after any
inline script the composition carried. At the moment a composition's own
script ran, window.__hyperframes was undefined and getVariables() was
unreachable: our documented API did not exist at the point authors are
told to call it.
Served order was gsap at line 6, the composition's init script at 20, the
runtime at 37. A probe inside the composition's IIFE recorded
hfTypeAtInit undefined with no variable keys, and the element rendered
its hardcoded fallback rather than the declared value.
The runtime is designed to load early. Its entry assigns __timelines,
installs the authored-opacity capture (whose own comment says it must run
while the document is still parsing), and exposes __hyperframes
synchronously, deferring real work to DOMContentLoaded. End-of-body
injection defeated all three, and nothing in it needs a parsed DOM, so no
defer is wanted.
Injects at head start instead, reusing the placement cascade
injectScriptsAtHeadStart already implemented rather than adding a fourth
copy of it. Head start rather than the closing tag so the runtime also
precedes author scripts inside head.
injectRuntime has exactly one consumer, the play server's composition
route. Every other surface reaches the runtime through the bundler, which
already injects into head, or deliberately serves raw.
Two registry blocks had independently worked around this by parsing the
authored attribute themselves. Those stay, but the workaround is no
longer the only way to read a variable at init.
Review catch. The baseline was taken from the first colour tween unconditionally,
so a tween declared "active" at index 0 set the reference its undeclared siblings
were compared against -- and the genuinely dim tween beside it was classified
active and given the wrong override. A partial migration could therefore end up
worse off than a composition that declared nothing.
The reference is now a declared "dim" tween if one exists, else the first
undeclared one: the heuristic stops drawing its inputs from records the
declaration has already spoken to.
Also pins the fallback for a malformed declaration -- a typo, a number, a null, or
a non-object `data` -- so a future tightening of the accepted union cannot quietly
turn an unrecognised value into a broken composition.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Classification by colour equality has to guess: it takes the first colour
tween's value as the dim baseline and calls everything else active. A
composition whose two states share a colour therefore has every tween
classified dim, and the caller's activeColor is silently dropped -- a real
failure, now covered by a test that fails without this change.
A tween may declare its state as data: { captionState: "dim" | "active" }.
GSAP passes unknown vars through untouched, so declaring costs nothing at
runtime, and resolution is per tween -- a composition can declare some and
leave the rest to the fallback, which is unchanged for anything undeclared.
This is the composition telling us what it built rather than us inferring it
from what it happens to look like. The data-driven caption templates already
author their state tweens from resolved values and never guess; this closes
part of that capability gap.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
## Why
#3094 fixed one way the mount and render paths disagreed, and added the gate that catches disagreement. It deliberately left the rest.
Four divergences are still live. Each one means a composition assembles differently depending on whether it is being previewed or rendered — the same class of defect that shipped three catalog components unstyled, just with smaller blast radii.
## How
Both paths now derive root discovery, scope identity, asset sources and order, hoisted links, variable carriers and nested-host enumeration from the shared module #3094 introduced. Each keeps its own I/O, which is where they genuinely differ. The compiler's local depth cap and root lookup and the runtime's three pre-filtered head parameters are gone; the runtime hands over the head node and lets the module decide what comes out of it.
Four behaviour changes, each stated by what actually differs rather than by the edit:
**Inline `<head>` scripts.** The compiler looped head scripts with a `src` branch and no `else`, so an inline one was silently discarded on render while the runtime ran it. That is losing code, not holding a convention — the runtime's answer wins. Head and content scripts now share one loop, head first, order preserved. A non-templated sub-composition with an inline head script went from **0 collected scripts to 1**, wrapped, body intact.
**`<link>` hoisting.** Conditional on render, unconditional on mount, so a templated sub-composition's webfont link was dropped in video and kept in preview. Hoisting is the superset and matches what the author declared. A templated composition with a stylesheet link went from **no external links to that link**. The parity fixture that previously recorded this shape as a known exclusion now gates it.
**Anonymous hosts.** With a host naming no id, the compiler fell back to the first declared composition and scoped to it; the mount left the content unflattened and injected its stylesheet into the host `<head>` **unscoped**, so a composition's CSS leaked into whatever mounted it. The compiler's answer wins. The injected rule went from a bare `.label { … }` to `[data-composition-id="scoped-text"] .label { … }`.
**Scope ids.** The compiler splits the CSS scope id from the script composition id; they differ only when a host names an id the content does not declare, and there the scripts follow the declared id so their self-referencing queries resolve. The runtime used one for both. The split wins: a host naming `captions-comp` over content declaring `captions` now emits scripts bound to `captions` while its CSS still scopes to `captions-comp`.
## Test plan
- [x] Unit tests added/updated
- [x] Manual testing performed
- [ ] Documentation updated (if applicable)
Core 1694 passing, producer 574 passing, the parity contract now gates the two divergences it can observe (the other two carry no contract field, so they are gated by unit tests naming the exact before/after). Lint 0, `typecheck:runtime` and the runtime preview guards clean, package cycles unchanged.
Characterization-first: both suites were run and recorded green before any decision moved, so a behavioural drift would surface as a red test rather than a silent difference.
**One assertion changed, deliberately.** A runtime test asserted that an anonymous host's composition is *not* flattened, and documented that as intentional. That premise is now false. What the test actually cared about — the root and its content present under the host — still holds and is still asserted; the "not flattened" claim flipped, and the test now also asserts the scoping that was missing.
## Not covered
The variable-carrier divergence and its `TODO(template-var-carriers)` are untouched by design, as is recursion on the mount path — a sub-composition containing its own `data-composition-src` is still silently dropped in live preview. Both are behaviour changes with their own units, and both are now one-line-ish changes because the shared module already reports what they need.
`runtimeScopeCompositionId` no longer falls back to the authored scope id. This is a functional change beyond the four above, surfaced in review: for an anonymous host with authored variable defaults, the runtime previously stashed them under the declared id, and now does not. It removes a runtime-vs-compiler divergence in the correct direction — the runtime was doing work the compiler never did, and the compiler is authoritative for a shipped composition — but a caller relying on runtime-only variable exposure loses it.
The three copies each of the flattened-root helper and the id assignment are left alone: they look mergeable and are not cheaply, and they touch the instancing contract the pixel harness guards.
## Worth knowing
The parity test's compiler arms import core's **built dist** while the mount arm imports source, so core must be rebuilt before that lane means anything after a compiler change. Skipping it produces a phantom divergence that looks exactly like a real one.
Rebased onto main (was 308 behind) and gated the new default-on behaviour on
the de-parallel-router canary, at 5%.
Default-ON without a ramp is a ~17x exposure jump: from ~6% of eligible
renders today to all of them, landing on profiles the opt-in trial never
covered (<=4 CPUs and Docker, ~12% of eligible renders between them).
0.7.60-0.7.64 is why that matters — every unclamped render reverted for five
consecutive releases and nobody noticed.
The gate reuses the breaker's own disarm: non-enrolled installs get an
explicit HF_DE_PARALLEL_ROUTER=false, because with default-ON polarity
deleting the var means ON. Setting the registry percentage to 0 is therefore
a full fleet-wide revert with no release.
Today's ~11% of installs routing is emergent — the product of eligibility
rules and a capped trial — so it drifts with fleet composition and cannot be
turned off without shipping. The point of the canary is that the number
becomes chosen and revertible, not that it is smaller.
Also replaces the registry test that pinned the percentage to 0. Its intent
was 'ramp only alongside the circuit breaker', but pinning 0 blocks the ramp
forever and never checks the wiring it names. It now asserts the wiring
directly, and fails if either the canary gate or the breaker consult is
removed.
Hold at 5% until PRINFRA-372 resolves: --workers auto crashes every worker on
macOS arm64 while --workers 1 is clean, and the router forces 3 workers.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
## Why
A composition mounted as a sub-composition lost its entire stylesheet and scripts whenever they were authored as siblings of the composition root inside its `<template>`. That shape is legal and common, so three catalog components — `oversized-cursor`, `device-frame-stage`, `touch-indicator` — rendered **completely unstyled** in the live preview.
`oversized-cursor` drew its pointer at 1280px against an authored `7cqw` (~134px at 1920), because `width: 7cqw` was never declared at all. Confirmed in the mounted document, where only the host's own `<style>` was present.
The rendered video was correct the entire time. This was a preview-versus-render divergence, and it survived a fully green test suite.
## How
**The fix.** `mountCompositionContent` collected assets from the composition root element, so sibling nodes were invisible to it. It now collects from the source node — a superset of the root, and the single point every mount path routes through (external fetch, inline template, nested). It also strips the mounted *clone* rather than the source: the previous code removed extracted nodes from the node it was handed, which on the inline-template path is a live `<template>` still in the document, so a remount would have found it emptied.
**Why nothing caught it.** Every CLI gate — `check`, `lint`, `validate` — reaches the compiler path through `bundleToSingleHtml`, and the compiler always collected from the whole template. Nothing in the CLI exercises the mount path, which is reachable only through the player and Studio. The repo's own parity test assembled a fixture two ways and deep-equalled a contract across them, but **both arms were static-compiler paths** — which is exactly why the runtime could drift unnoticed.
**The gate.** A third arm mounts the same fixture through `loadExternalCompositions` and extracts the same contract. Three fixtures run through all three arms, one authoring its assets as root siblings — the shape that broke. `authoredStyleSignatures` was already in the contract and is exactly the signal that was missing, so no contract field was added.
**The owner.** Both paths answer the same questions — which nodes are a composition's assets, in what order its scripts run, how its CSS is scoped, which head elements hoist, how nested hosts are discovered, which element carries variable defaults. They now have one module to answer them from. It holds decisions only, never I/O: the two paths differ at their boundary in ways that are essential (Node + linkedom + synchronous + strings; browser + fetch + live DOM + script *execution*), and the runtime ships as a bundle to a CDN, so anything it can reach is weight and risk. Hence zero imports, a structural input type rather than `Document`, and a test asserting the import surface stays empty.
Routing both paths through that module is deliberately **not** in this PR — it changes behaviour in four places (below) and belongs where each can be judged and reverted on its own.
## Test plan
- [x] Unit tests added/updated
- [x] Manual testing performed
- [ ] Documentation updated (if applicable)
Every claim here was verified in both directions rather than assumed.
The fix's regression test fails on pre-fix code and passes after — run both ways. The parity arm was proven able to fail: with the fix reverted, the sibling fixture fails and names the composition's own scoped selector against an empty list, while the other two fixtures stay green, so the arm is targeted rather than blanket-red. Restored, 7/7 pass.
`bun run lint` exits 0. Core: 1690 tests passing, plus `typecheck:runtime` and `lint:runtime-preview-guards` clean. Producer: 571 tests passing. The shared module's own defect was reproduced by mutation — collecting from the composition root instead of the whole template fails three of its 17 tests, including the sibling case.
## Found while doing this, not fixed here
Deriving the shared decisions surfaced **four more live divergences**, none of them the reported bug, each a behaviour change to decide deliberately:
- The compiler silently drops inline `<head>` scripts — it handles the `src` case and has no `else` — while the runtime executes them.
- `<link>` hoisting is conditional on render and unconditional on mount, so a templated sub-composition's webfont link is dropped in video and kept in preview. This one reproduces under the new parity arm and is explicitly excluded from its contract, with the reason recorded in the file.
- For a host naming no id, the compiler falls back to the first declared composition and scopes to it; the runtime mounts the content whole, unflattened and unscoped.
- The compiler keeps two scope ids, CSS and scripts, so a script's self-referencing query resolves when a host names an id the content does not declare; the runtime keeps one.
Separately: the mount path **does not recurse at all**, so a sub-composition containing its own `data-composition-src` is silently dropped in live preview. The compiler has a dedicated recursive-discovery suite; the runtime has no nesting, circularity or depth coverage.
Each is recorded with its evidence in the commit messages here, and sequenced so the behaviour-changing ones land separately, after this gate exists to catch a mistake in them.
## Not covered
This does not heal the published docs by itself. Previews load `@hyperframes/player` unpinned, but the player bakes a version-pinned core runtime URL at build time, and core and player publish in lockstep — so the live catalog only recovers after both ship. There is no hotfix path short of a release.
The calibration contract deferred this until the stability check came back
dirty. It did: the first fleet read found 304 installs (1.08%) reporting both
values for a canary whose percentage never moved, and the genuinely anomalous
ones could not be separated from a developer toggling HF_CANARY_*, because the
assignment alone is identical in both cases.
resolveCanary has always computed the reason and canaryEventProperties dropped
it. Now every canary emits canary_reason_<name> beside its assignment.
Deliberately outside the $feature/ namespace: PostHog treats those as flag
values, and a non-boolean there would corrupt the flag's own breakdowns.
Two of the six wire values are immediately useful beyond override attribution.
'excluded' identifies CI installs, which today have to be dropped by joining
on is_ci — conflating them with out_of_cohort is what made the first accuracy
read look like a significant failure (9.22% against a 10% target) when it was
not. 'no_unit_id' surfaces the fails-closed corner.
The reason is optional on the core helper so existing callers are unaffected.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
carriedSectionsFrom() decided whether a ## Usage section was generated by matching
its first line against a list of historical opener phrases — so a hand-written
Usage section that happened to open that way was classified as generated and
silently deleted on regeneration. Ownership is now purely set membership: a
section is generated iff its heading is one the template emits, and ambiguous
'usage' is no longer in that set (the template never emits it), so any ## Usage is
carried. Exported carriedSectionsFrom behind an entrypoint guard and added two
executable preservation fixtures. Flagged by Magi (#5).
My generator rebuild replaced the '## Related topics' section (still required by
docs/AGENTS.md) with the provenance footer, dropping it from all 168 generated
Catalog pages. Emit it again as the final section so pages end with it, and stop
carriedSectionsFrom() breaking at the footer marker so a human section appended
below the generated tail survives a regeneration. Adds a per-page regeneration
assertion so a future drop fails CI. Flagged by Magi (P1) and Rames.
Three R3 findings.
The redactor's segment classes were ASCII `\w`, so `/数据/客户/秘密视频.mp4` and
`/data/客户/secret.mp4` went out verbatim — and the generic redactor also feeds
CLI telemetry and producer observation messages, where no known-path list
compensates. Segments are now defined by their delimiters instead of an
alphabet, which is correct for every script by construction rather than
requiring Unicode classes to be kept correct. The bare-relative lookbehind had
the same ASCII assumption and let a match start mid-token, redacting
`客户/秘密/视频.mp4` to `客户[path]`; it is now a token boundary, and
bare-relative runs before absolute so it claims the whole token.
sanitizeProbeFailure cast the rejection reason to Error and read `.message`.
An injected probe can reject with anything, so `Promise.reject("failed")` gave
`undefined` and threw inside the redactor — converting a returned failure
result into a rejected promise. Normalized at the boundary, and
redactKnownPaths no longer throws on a non-string.
The contract only admitted .ts/.js/.mjs/.cjs, so it missed shipped shell and
Python callers. frame_strip.sh passed a user-controlled path as ffprobe's last
positional with no terminator; render-and-composite.sh had four more. Both
fixed, and the sweep now covers .py/.sh. Python list argvs are bracket
literals so they get the same position check; shell command lines get a
separate presence check, because checking position there needs a shell parser
— stated as the weaker guarantee it is rather than implied to be equal.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The generic scrub still missed a relative path with no `./` prefix:
`customer/acme-secret/video.mp4` and `assets/bgm.mp3` reached telemetry
completely unredacted, because the absolute rule needs a leading slash and
the `./` rule needs the dot. Adds a rule for them that still leaves `N/A`,
`24/1` and `48000/1001` alone.
Shape matching is a net with holes by construction, so audioPadTrim now
also redacts the exact path it put in the argv, plus its basename, before
the generic scrub runs. It built the argv, so it does not have to guess.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
redactTelemetryString enumerated roots — /Users, /home, /opt, /tmp and a
handful more — so a project on /data, /Volumes, an NFS mount or any root a
user invented reached telemetry verbatim. Relative paths and bare basenames
were never redacted at all, and audioPadTrim routes raw ffprobe stderr
through this on every probe failure.
Now redacts by shape: absolute paths under any root (two or more segments,
so N/A and a 24/1 frame rate are not mistaken for one), relative paths
including dash-prefixed ones, and bare basenames with an asset extension.
URLs still keep their host and drop only the query.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
#2740 added `--` to one of nine independent ffprobe invocations, so the
bug class it closed stayed open everywhere else while CI reported it
fixed — the regression test asserts the argv of that single site.
Reproduced on ffprobe 8.1.1: an asset named `-intro.mp4` probes fine
through extractMediaMetadata but fails with "Missing argument for option
'intro.mp4'" in audio pad/trim (mid-render), `hyperframes init`, whisper
duration probing and webmAlphaCheck. hevcPreviewLint catches and returns
false, so a dash-prefixed HEVC preview silently passes the lint rule.
Terminated at all of them:
producer/services/render/audioPadTrim.ts (x2)
producer/plan-parity-analysis.ts
cli/commands/init.ts
cli/utils/webmAlphaCheck.ts
cli/whisper/transcribe.ts (x2)
core/mediaGradeAnalyzer.ts
lint/hevcPreviewLint.ts
audioPadTrim's runFfprobeJson is a near-verbatim clone of the engine's
runFfprobe and structurally cannot add the terminator itself, because
callers bake the input path into `args`. It now asserts the terminator
is present rather than letting a dash-prefixed path through, takes the
same stdio ["ignore", ...] as the engine helper, and redacts its stderr
— it was throwing raw ffprobe output, which echoes the input path, into
logs and telemetry.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Extends the studio-preview fix to the render path and the asset-discovery
utilities, which share the same resolver and had the same defect.
`rewriteAssetPath` takes an optional `assetExists` probe. A plain relative ref
authored in a sub-composition (`_shared.css`, `clip.mp4`) is re-pointed at the
composition's own directory when that sibling exists on disk; project-root refs
with no sibling (the registry's `assets/logo.png` convention) stay as authored.
Callers that can see the filesystem supply the probe, so the module stays free
of node:fs.
Also fixes a second defect in the inliner: `<head>` <link> hrefs and external
script srcs are hoisted into the root document but never went through the
rewrite at all, so even the documented `../` form escaped the project and 404'd
at render time.
Wired into the preview bundler, the producer compiler, the studio preview
builder, the HEVC preview lint, the project lint's asset scans, publish proxy
baking, and media-treatment source resolution.
R6/R7 blockers.
An already-open Studio kept emitting server-side render telemetry after
another process disabled CLI telemetry. refreshTelemetryPosture() only ran
while serving a fresh SPA document and on /api/telemetry-identity, which
Studio has no consumer for, so the render POST and its async outcome used
the posture cached when the preview server booted. It now refreshes at the
render boundary and again immediately before the completion/error event,
so an opt-out during a long render is honoured.
The identity tests were passing vacuously: their mocks omitted
readConfigFresh and resetTelemetryPostureCache, and the resulting
missing-export error was swallowed by the refresh's own catch. Mocked
properly, plus the enabled -> external disable -> next response transition
and the suppression path at the layer that drops the event.
A full reset also did not persist its new lineage in a long-lived process:
syncInstallState returned early on a process-lifetime memo even after
~/.hyperframes was deleted, so install-state was never recreated and the
next config-only re-mint rolled a third seed instead of inheriting the
second. The memo is now revalidated against the file.
Also drops a stale reference to assertNoOverdueCanaries and stops the
workflow and docs claiming the sunset job routes anything to the owner —
it names them in the run log and notifies nobody.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
- A long-lived preview cached its telemetry posture in two places
(readConfig and shouldTrack). Running `telemetry disable` in another
terminal left it resolving canaries and injecting the CLI id for hours.
Both caches are now dropped together at a request boundary.
- Studio minted and shipped a telemetry id for every render regardless of
the browser profile's opt-out, and the server emitted the outcome under
CLI policy, which cannot see localStorage or DNT. The browser now sends
an explicit telemetryOptOut, distinct from an old client's omission.
- Any non-empty HYPERFRAMES_PREVIEW_HOST disabled the DNS-rebinding guard,
so even a loopback bind accepted a hostile Host. The guard now holds for
loopback binds and, on a LAN bind, admits only names this machine
answers on.
- sunsetAfter had no reader of the current date. A scheduled workflow runs
scripts/check-canary-sunset.ts weekly, so a failure lands on the
rollout's owner rather than on an unrelated PR author.
- The install-state seed memo outlived `rm -rf ~/.hyperframes`,
resurrecting a cleared cohort. Removed; it only saved a read on a
readConfig cache miss.
Docs updated for the Host rule and the 100% exclusion carve-out.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
R5 blockers
- Negative install-state latch was cached for the process lifetime, but
only `true` is monotonic across processes. A long-lived preview server
held a stale `false` and could re-enrol after another process tripped
the breaker. Only the positive is cached now; `false` re-reads.
- The real breaker writer used writeConfig(), which collapses
{ok:true, mirrored:false} to success, so a run that mirrored nothing
reported done with the latch only on the erasable store. It consumes
writeConfigWithResult and retries until both stores carry it.
Bucketing integrity
- Storage-restricted Studio profiles all bucketed on the literal
"anonymous": computed against the shipped hash, 100% of them were
enrolled in calibration-50 rather than 50%, and they merged into one
PostHog person. Per-session random id instead — persists nothing.
- bucketSeed had read/write authority backwards: install-state is
write-once authoritative, but readConfig took config.json's blindly, so
the stores could hold different seeds until a re-mint flipped every
cohort. Merged on read, like the latch.
- An unwritable ~/.hyperframes with no config.json re-minted per call,
re-rolling the seed on every command, and the "cohorts will not be
stable" warning was unreachable on that path.
- A corrupt PRE-MOVE state file was never deleted, so a machine reset
with `rm -rf ~/.hyperframes` reported predecessorFound/stateFileCorrupt
forever — poisoning the exact metric this work exists to produce.
Opt-out honoring
- CLI canary decisions memoized per process, so `hyperframes telemetry
disable` during a running preview server was ignored for hours while
the server kept serving pre-opt-out decisions. The memo is keyed on the
telemetry posture.
- shouldTrack() memoized, contradicting policy.ts's documented "not
memoized" contract that policy.test.ts asserts.
- The Studio override path resolved the bucket unit eagerly as an
argument, minting and PERSISTING a tracking id for an opted-out profile
— a value evaluateCanary discards unread.
- Storage reads could throw out of telemetry into a post-commit catch
block, reporting an already-committed edit as failed.
- readConfig printed an unsilenceable stderr warning on every invocation
for installs that opted out of telemetry entirely.
Host split
- isLoopbackHost rejected 0.0.0.0, so the documented
HYPERFRAMES_PREVIEW_HOST LAN mode silently lost CLI→Studio identity
stitching and split one user across two PostHog persons. Identity is
now allowed when the operator explicitly opted into LAN binding.
- Corrected the comment claiming the guard refuses spoofed Hosts: a
non-browser client sets Host freely. It is a browser DNS-rebinding
mitigation, not access control, and now says so.
Semantics and test hygiene
- percentage:100 did not mean everyone — exclude and no_unit_id sat above
the fast path, so the registry's "delete the entry at 100" step was an
unstaged flip for CI and seedless installs.
- CLI cohort adoption returned before evaluateCanary, dropping Studio's
own webdriver exclusion.
- overdueCanaries() was asserted against wall-clock time, so the whole
core suite would go red on 2026-09-15 for every unrelated PR; and `>`
against midnight made a canary overdue ON its sunset date.
- Statistical assertions ran on unseeded randomUUID() populations tight
enough to fail ~1 run in 200. Seeded.
Also: broke a config -> policy -> transport -> config import cycle by
moving POSTHOG_API_KEY to a leaf module.
Tests: 2347 CLI (bundle absent), 3153 Studio, 1450 core. Fault injection
covers the latch, seed authority, LAN identity, webdriver exclusion and
the anonymous-bucket fix. Two pre-existing tests asserted behaviour these
findings identify as wrong (shouldTrack memoization, 100%-excludes-CI)
and were rewritten with the reasoning stated.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Six findings from review, none behaviour-critical on their own but three
of them quietly corrupt the data the rollout is judged by.
Endpoint no longer serves bucketSeed (studioServer.ts). Studio gets its
canary answers from the injected decisions map now, so nothing needed the
seed over HTTP — and an unauthenticated local endpoint is a strictly
worse place for it than a script scoped to Studio's own document. The
endpoint itself predates this PR and still serves distinctId, so it also
gains a Host guard: a remote page can rebind its hostname to 127.0.0.1
and read the response as same-origin, but the request still carries THAT
hostname, which is what makes it refusable.
predecessorFound no longer reports corruption as a fresh install. It
returned null for both "file absent" and "file unreadable", so a partial
disk write looked like a new machine — understating recoverable churn,
the one thing the field measures. Now distinguishes absent from corrupt
and emits install_state_file_corrupt alongside.
A mangled markerAt no longer discards a salvageable bucketSeed. markerAt
is only a timestamp and can be restamped; the seed cannot be recovered,
and losing it silently re-rolls the install's cohort.
The seed backfill no longer ignores its write result. An unwritable
~/.hyperframes meant a different seed every invocation with no
diagnostic, and made the field's own "backfilled once" docstring false.
Warns once per process with the underlying error.
FNV-1a's ASCII constraint is now explicit rather than incidental. It
hashes UTF-16 code units while reference FNV-1a is byte-oriented, so the
two agree only on ASCII; the registry's kebab-case assertion is what
makes non-ASCII unreachable, and both ends now say so. Not a live bug —
names are kebab-case and units are UUIDs.
de-parallel-router is pinned at 0%. The registry is data, so a ramp is a
one-line edit with no review surface, and its own description says to
ramp only alongside the circuit breaker.
Tests: 8 new (corruption vs absence, seed salvage, backfill write
failure, 17 host-guard cases, registry pin). One existing test asserted
predecessorFound: false on corruption — that was the bug, updated with a
note. Fault injection: restoring the old corrupt handling fails 4.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Both reviewers flagged the same gap: seed injection was gated on
telemetryShouldTrack(), but canary EVALUATION was not. An install with
DO_NOT_TRACK=1 was still bucketed and still had real code paths flipped
(e.g. HF_DE_PARALLEL_ROUTER), silently and unmeasurably.
A canary is a measured rollout — we enrol a slice precisely so it can be
compared against everyone else. An install that sends nothing can't be
compared, so enrolling it buys no signal and only changes that user's
code path, on an experimental feature, without their knowledge. That is
the wrong side of an opt-out.
Resolves to a new `telemetry_opt_out` reason BEFORE bucketing, so no
cohort is assigned at all. Distinct from `excluded` because "why is my
canary off" has a very different answer for CI than for opted-out, and
the reason never reaches telemetry by construction.
Covers every opt-out route: persisted preference, the runtime env vars
and dev/telemetry-disabled builds via policy.ts, and Studio's
hyperframes-studio:telemetryDisabled.
An explicit HF_CANARY_* / ?hf_canary_*= override still wins — a
deliberate local choice, not silent enrolment, and the documented way to
exercise a canary with telemetry off.
The CLI check mirrors shouldTrack() rather than importing it: client.ts
already imports canary.ts for canaryEventProperties, so depending on it
would be a cycle. Both read the same two inputs, so they cannot disagree.
Tests: 9 new across CLI and Studio (preference off, each runtime
override, no bucket assigned, override still honoured, flag properties
all-false). Fault injection: removing the CLI gate fails 6, removing the
Studio gate fails 3.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Verified against packages/*/src env reads: 57 distinct HF_*/PRODUCER_*
toggles pre-existing this branch (the raw grep said 59, but two of those
are HF_CANARY_TEST_* fixtures introduced by this branch's own tests).
The number is cited externally now, so it should match what the repo
actually has.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Registers two canaries that gate nothing — `calibration-10` (10%) and
`calibration-50` (50%) — so the rollout mechanism can be proven against real
traffic before any real feature depends on it. Zero behavioural risk: they are
read by nothing.
They answer what the unit tests structurally cannot. The tests bucket
generated UUIDs and weight every install equally; real render volume is
heavily skewed toward a few heavy installs, and real install ids churn (~25x
more distinct ids over 30 days than in any single day on the desktop render
population).
Four checks, pre-registered in the docs so the read is not post-hoc:
1. ACCURACY — does 10% land at 10%, install-weighted AND event-weighted?
2. DRIFT — how fast does CUMULATIVE exposure climb above target as ids churn?
The instantaneous share is flat by construction; the set of installs
enrolled at some point is not.
3. STABILITY — does any install ever change cohort? Must be zero. Percentages
are held FIXED for the window precisely so a flip is unambiguously a bug;
during a real ramp a false->true flip would be correct instead.
4. CROSS-SURFACE — do the CLI and Studio bindings agree for the same install?
A CLI-launched Studio adopts the CLI id, and 16,961 installs currently
share an id across both surfaces, so this is measurable.
Plus an independence check: overlap between the two calibration canaries
should be ~p1*p2 (~5%), not ~min(p1,p2) (~10%, which would mean every canary
lands on the same unlucky cohort).
The docs also record what calibration CANNOT fix: per-install cohorts never
flip, but a person who wipes their config gets a new id and a fresh roll.
Preventing that needs stable identity across resets, and both candidates were
rejected — hardware fingerprinting correlates the cohort with hardware (fatal
for a rendering experiment, and it survives uninstall) and account identity
covers only ~3.6% of local rendering installs. The drift is therefore a
measured, accepted limit, and the point of calibrating is to size it and pick
canary window lengths accordingly.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Replaces the single `canaries: "a,b"` telemetry property with PostHog's own
flag shape, one property per registered canary:
$feature/canary-de-parallel-router: "true" | "false"
PostHog treats `$feature/<key>` as a first-class flag property, so breakdowns,
funnels split by cohort and the experiment surfaces work on a canary with
nothing configured server-side. The decision still happens locally: the render
path forbids render-time network calls, behaviour must not depend on analytics
being reachable, and neither the CLI nor Studio ships posthog-js (both
hand-roll a batch POST, so there is no SDK to evaluate a real flag with).
Decide locally, analyse natively.
Two decisions worth recording:
- BOTH ARMS ARE EMITTED. A non-enrolled install reports "false" rather than
omitting the property. Absent means "this build predates the canary", which
is a different fact from "this install is control" — collapsing them makes a
ramp unreadable, because you cannot separate a control group from an old
version.
- KEYS ARE NAMESPACED with a `canary-` infix. A real PostHog flag namespace
already exists in this project, owned by the web app (`enable-chat-tab`, set
by posthog-js from `$lib=web` events). Namespacing guarantees a canary key
can never alias a real flag key and have the two fight over one property.
Values are the strings "true"/"false" to match how PostHog records boolean
flag values, so the property is directly comparable to a real flag.
98 core / 1437, 166 cli / 2194, 269 studio / 2982 green; tsc clean across all
three packages.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Adds the Studio (browser) binding so a canary can span the CLI and the editor,
and fixes a bundling mistake the studio test suite caught.
## The binding
Same public API as the CLI — `isCanaryEnabled("name")` — so a call site reads
identically whether it runs in Node or the browser. Three inputs differ:
- UNIT ID: `resolveStudioDistinctId()`, which already adopts
`window.__HF_CLI_DISTINCT_ID` when the CLI launched Studio. A CLI-launched
Studio therefore lands in the SAME cohort as the CLI: a rollout spanning
render and editor is coherent for that user instead of enrolling their
terminal but not their editor. A test pins that the id is passed through
unmodified — prefixing or re-hashing it would silently break that parity.
- OVERRIDE: no `process.env` in a page, so `?hf_canary_<name>=on` mirrored
into sessionStorage. Session scope is deliberate. A URL is the right carrier
(shareable — "support: open this link"), but persisting a URL-borne override
to localStorage would let one click silently pin a browser into a cohort
forever, long after anyone remembers why. Closing the tab is the reset;
`=reset` clears it explicitly.
- EXCLUSION: `navigator.webdriver` stands in for the CLI's `is_ci`. Automated
browsers mint a fresh localStorage id per run, so they would hop cohorts
between runs — noise in the signal, nothing learned about real users. An
override still reaches them, which is how you test a canary under Playwright.
Studio's `trackEvent` now attaches `canaries` to every event, mirroring the CLI.
## The bundling fix
Importing the `@hyperframes/core` barrel into studio browser code broke two
unrelated hook test files with an esbuild TextEncoder invariant violation. The
barrel re-exports the whole core surface (parsers, lint, studio-server), so it
drags a Node-oriented dependency graph into a browser bundle — the test
failure was the symptom, the bundle bloat was the bug.
`@hyperframes/core` now exposes `./canary` and `./canary-registry`, declared in
packages/core/package-subpaths.json (the generated source of truth for exports —
hand-editing package.json is reverted by the sync script) and marked
`environments: [browser, bun, node]`. Both the studio AND cli bindings import
the leaf modules; the CLI gets the same benefit for a different reason, since
this resolves on the startup path — the reason the producer is lazily loaded.
Verified: the two hook files pass again; 269 studio files / 2982 tests, 98
core / 1433, 166 cli / 2194 green, `bun run lint` clean including the subpath
check. Fault-injection confirms both design decisions are pinned — swapping
session for local storage fails the scope test, prefixing the unit id fails the
CLI/Studio cohort-parity test.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Follow-up to the canary primitive.
Telemetry: every event now carries a `canaries` property listing the cohorts
the install is enrolled in, attached in `trackEvent` so it lands on ALL
events rather than renders only — a staged rollout is only as useful as the
ability to split any metric by cohort. Resolved after the shouldTrack guard,
so opted-out installs never pay for it, and omitted entirely (not null or "")
when the install is in no canary, since PostHog treats those as real values.
Test hardening, after validating the shipped code against 60k synthetic and
101 real fleet install ids:
- Pin FNV-1a against canonical vectors, AND assert the shipped canaryBucket
actually uses that hash. Without the second assertion the first is
tautological — it would only prove the test's own copy is correct while
canary.ts drifted to a different hash, silently reshuffling every live
cohort. Fault-injection confirms only this assertion catches a hash change;
the distribution tests stay green because a perturbed hash is still
well-distributed.
- Tighten the share test from a 0.6x-1.4x band to +/-1 percentage point.
Measured error was 0.16pp at n=60k, so the old band would have passed a
badly skewed hash.
- Add chi-square uniformity across all 100 buckets (chi2 89.0 vs 148.2
critical at p=0.001). A lumpy hash yields roughly the right total share
while overloading some buckets, so the share test alone cannot catch it.
- Assert N concurrent canaries enrol binomially rather than in lockstep:
8 canaries at 10% put ~43% of installs in none and zero in all eight,
matching binomial(8, 0.1). Correlated slices would put ~10% in all eight.
Also verified 88,443 of 88,448 fleet install ids are well-formed UUIDs; the
5 that are not fail closed, which is the intended direction.
Docs: docs/contributing/canary-rollouts.mdx, registered in docs.json (an
unregistered page is invisible in the nav).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Adds a reusable staged-rollout primitive so a change can ship to a stable
slice of installs instead of all-or-nothing.
The gap it fills: the repo carries ~49 HF_*/PRODUCER_* booleans and every one
is binary — a feature is either off (and therefore unexercised on real
traffic) or on for everyone (and therefore a fleet-wide bet). The
parallel-drawElement router sat in that gap for weeks: default-off produced
almost no signal, and flipping it default-on would have exposed 100% of
eligible installs at once.
Shape:
- packages/core/src/canary.ts — pure evaluator. No fs, no network, no
`process`; the caller supplies the unit id and overrides, so it imports
cleanly into the CLI, producer, engine, studio-server, the browser-side
studio bundle and the embeddable player. FNV-1a rather than node:crypto for
the same reason.
- packages/core/src/canaryRegistry.ts — every rollout in one table (name,
percentage, owner, description, sunsetAfter), so "what is rolling out, to
whom, owned by whom" is answerable without grepping 49 env vars.
- packages/cli/src/telemetry/canary.ts — supplies the three things only the
CLI knows: anonymousId, the HF_CANARY_<FEATURE> override, and is_ci.
Day-to-day API is `isCanaryEnabled("name")`.
Three properties the tests pin, because getting them wrong is subtle:
- Slices are INDEPENDENT per feature: the bucket hashes `feature:unitId`, not
the id alone. Bucketing on the id would hand every concurrent experiment to
the same unlucky cohort and make two rollouts unreadable apart.
- Ramping is INCLUSIVE: `bucket < percentage`, so widening 10 -> 25 keeps the
original cohort and before/after comparisons survive the ramp.
- It fails CLOSED: no unit id, unknown name, or CI install means not enrolled.
A canary exists to bound blast radius, so "we don't know who this is" must
never mean "enrol everyone".
Registry entries also carry a sunset date, and a test fails once one is past
due — a canary that outlives its rollout is a permanent fork of the product
with none of the review a permanent fork would get.
Ships with de-parallel-router registered at 0%: inert, and ready to ramp in a
patch release once #2840 lands.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>