App
Flags & experiments
Kill switches, rollouts, targeting, and A/B tests as ONE primitive, flag(). Decided per visitor, server-side during SSR, with zero JS for the decision. OpenFeature connects through one decide() call. csr=false pages A/B test with no extra cost.
On this page
- Declare — the second argument is the shape
- Read — by calling the flag
- Branch — pick, one verb, two forms
- Who is the visitor?
- Vendor values — .value()
- OpenFeature — decide({ source })
- The precedence chain
- QA override
- Mutual exclusion — the layer option
- Measurement — decide({ exposure })
- One world across teams
- The flag manifest
A kill switch, a staged rollout, a targeting rule, and an A/B/n test are the same thing: a name with variants, decided per visitor. One primitive owns that decision: flag(). The decision occurs server-side during SSR. Thus the selected variant is simply what renders. There is no flicker, no client SDK, and no JS for the decision. A csr = false page A/B tests with no extra cost.
You read a flag when you call it. You branch with pick. You connect a vendor (OpenFeature) with one decide() call. That is the full surface.
Declare — the second argument is the shape
import { flag } from 'ogygia/flag';
export const checkout = flag('new-checkout'); // kill switch — off until decided on
export const promo = flag('summer-promo', 10); // number → sticky rollout to 10%
export const beta = flag('beta', (c) => !!c.visitor?.beta); // function → targeting
export const hero = flag('hero', { control: 80, bold: 20 }); // record → weighted variants (ratios)- omitted → a kill switch, off for all visitors. This is the safe default.
- a number → the sticky percent of traffic that gets the feature.
- a function → per-request targeting.
true= on.false= off, and a rollout cannot include the visitor again.undefined= fall through. It runs on each request. Thus you can read a cookie, an env var, or a DB row: runtime toggles without a redeploy. - a record → weighted variants. Weights are ratios (
{ control: 3, bold: 1 }is 75/25). The first key is control.
Read — by calling the flag
'/': page(Home, {
load: (c) => ({
dark: checkout(c), // boolean
variant: hero(c), // 'control' | 'bold' — sticky per visitor
}),
}),The decision is deterministic and sticky. The same visitor gets the same answer on each request, from a hash of (flag name, visitor id). There is no storage, no cookie write, and nothing to sync.
Branch — pick, one verb, two forms
pick means: “here is a map of variant → thing; give this visitor their entry.”
A page slot (no c — the router supplies it per request):
import Hero from './Hero.svelte';
import HeroBold from './HeroBold.svelte';
'/': page(hero.pick({ control: Hero, bold: HeroBold }), { load }),$infer does not see a difference. The types of the page come from the load. Thus the arms are free. The arms do not have to be different files. The schedule of a component rides its import attribute. Thus two bindings of ONE file are two different pages. That answers a real question as a real experiment: does full hydration beat islands?
import Lab from './Lab.svelte'; // pure server HTML — zero JS
import LabLive from './Lab.svelte' with { wake: 'load' }; // the SAME file as one live island
export const csrMode = flag('csr-mode', { static: 50, hydrated: 50 });
'/lab': page(csrMode.pick({ static: Lab, hydrated: LabLive }), { load }),A value (you have c, so pass it and get the answer):
const trialDays = pricing.pick(c, { control: 7, generous: 30 }); // 7 | 30, typed + total
const label = hero.pick(c, { control: 'Buy now', bold: 'BUY →' });
const cfg = hero.pick(c, {
control: { columns: 2, video: false },
bold: { columns: 1, video: true },
});TypeScript makes the map cover each variant. The return type is the union of its values. pick pays at 3 or more variants. For a boolean flag, call it. For two variants, a ternary is sufficient.
Who is the visitor?
Stickiness needs an identity. Declare it one time, on the route table:
export const app = routes(table, {
visitor: (c) => session_from(c.cookies), // → { sub: 'u123', … } or undefined
});That becomes c.visitor in each place. Flags stick on c.visitor.sub. It is also the identity that mounts sign onward to other teams. Outside the router, or for anonymous traffic, the fallback is an og-vid cookie, then anonymous. Anonymous visitors always see control. Thus a split never changes for a visitor you cannot recognize.
For sticky rollouts on logged-out traffic, make the cookie:
import { anonymousVisitor } from 'ogygia/router';
const anon = anonymousVisitor(); // { cookie?, days? }
routes(table, { visitor: (c) => session(c) ?? anon(c) });Vendor values — .value()
A flag can carry a vendor-authored payload: config edited in the dashboard of the vendor, not in code. Validate it with a schema. An invalid payload is ignored. It never enters. That is the same trust-boundary law as for a body schema:
import * as v from 'valibot';
export const banner = flag('promo-banner', 0, {
value: v.object({ text: v.string(), tone: v.picklist(['info', 'warn']) }),
fallback: { text: '', tone: 'info' }, // when the vendor is silent or the value is invalid
});
banner.value(c); // typed payload — never undefinedOpenFeature — decide({ source })
Native flags need no setup. To decide flags with a vendor, connect it one time. ogygia depends on no vendor SDK. You give decide your own OpenFeature client:
import { OpenFeature } from '@openfeature/server-sdk';
import { LaunchDarklyProvider } from '@launchdarkly/openfeature-node-server';
import { decide } from 'ogygia/flag';
import { openfeature } from 'ogygia/flag/openfeature';
OpenFeature.setProvider(new LaunchDarklyProvider(env.LD_KEY));
decide({ source: openfeature(OpenFeature.getClient()) });The source resolves one time per request, over exactly the flags that your app declares. After that, each read stays sync. From that instant, a vendor-decided flag and a native one look the same: same calls, same pick, same federation carry. Decisions are validated against your declared variants. A vendor typo cannot invent a bucket. If the vendor is slow or down, each flag degrades to its native rule. It never gates the first byte.
No vendor SDK? Speak the OpenFeature remote protocol directly:
import { ofrep } from 'ogygia/flag/openfeature';
decide({ source: ofrep({ url: 'http://flagd:8016' }) });Custom logic or a database? A source is only a function:
decide({ source: async (queries, c) => {
const rows = await db.flags(c.visitor?.sub);
return Object.fromEntries(rows.map((r) => [r.name, r.variant]));
}});On plain SvelteKit (no ogygia router),
importyour flags module inhooks.server.ts. Then each flag is registered before the first request primes the source. Router apps import all route modules at start. Thus their registry is always complete.
The precedence chain
Each read resolves in this order. The first hit wins:
?og-exp override → carried claims → the source → the native rule → control.
- override — a QA force from the URL (below).
- carried claims — a decision that an upstream shell already made (federation).
- source — the vendor, if
decide({ source })set one. - native rule — your targeting function, weights, or rollout.
- control — the first variant (or
off).
QA override
Force a variant from the URL. It wins over everything, stickiness included:
/lab?og-exp=csr-mode:hydrated
/?og-exp=hero:bold,new-checkout:onDev-only by default. In production, an open URL override would let a visitor force unfinished features on, and pollute your data. Thus production ignores ?og-exp out of the box. Permit QA with a signal that a visitor cannot forge:
decide({ overrides: (c) => c.visitor?.roles?.includes('qa') ?? false });Mutual exclusion — the layer option
The problem. Two experiments touch the hero section: a redesign, and an autoplay video. If one visitor lands in BOTH treatments and conversions move, you cannot say which change did it. The two measurements contaminate each other. Experiments on the same surface must never share a visitor.
The mechanism: lanes. Flags that share a layer string form a group:
const heroRedesign = flag('hero-redesign', { control: 50, bold: 50 }, { layer: 'hero-surface' });
const heroVideo = flag('hero-video', { control: 50, video: 50 }, { layer: 'hero-surface' });For each visitor, the layer makes ONE sticky decision, before the logic of a member runs. It hashes (layer name, visitor id) over the sorted member names. That selects the owner for the visitor. Think of all traffic in N equal lanes, one lane per member. A read of a member flag then starts with one question: am I the owner for this visitor?
- Not the owner → answer control, immediately. The targeting and the weights of the flag never run for that visitor.
- The owner → continue as if the layer did not exist, inside your own lane.
Walkthrough: visitor puru hashes into the lane of hero-video. heroRedesign(puru) → 'control', always. Its 50/50 never executes. heroVideo(puru) → its own 50/50. The same inputs hash the same way on each request and on each replica. The names are sorted, so module import order can never change a lane. There is no storage, no cross-server coordination, and nothing to sync.
The arithmetic to remember. A member only experiments inside its own lane. Thus its real exposure is its split divided by the member count. A 50% variant in a 2-member layer reaches 25% of all traffic. Add a third member, and each lane shrinks to a third.
What outranks the layer. The override, carried claims, and the vendor source all sit ABOVE the lane gate in the precedence chain. ?og-exp=hero-redesign:bold shows QA the variant, also for a visitor that the other member owns. A carried decision from an upstream shell is applied as-is. A vendor that answers for a flag bypasses the lane. The vendor then owns exclusion. Do not run the same surface in both systems.
Measurement — decide({ exposure })
ogygia ships no metrics pipeline. The decision is the primitive. The count is the job of your analytics. The one seam is an exposure sink. It fires one time per request per flag, on the first read. Under SSR, the decision is the render of the variant. Batching is built in:
decide({
exposure: (events) => analytics.track(events), // an array of { name, variant, sub, at }
batch: { max: 25, ms: 2000 }, // flushed at the cap, the timer, or request end
});A sink that throws never breaks a request. The tail drains at the end of each request (serverless-safe). For logs and data-og-exp stamps, stamp(c) gives name:variant:
<section data-og-exp={data.stamp}>…</section>One world across teams
“Carried” in the precedence chain is the federation tier. Each flag that a request decides registers its bucket itself. When a shell mounts the apps of other teams, the full world of the visitor travels in the signed claims. No person lists anything by hand. A forgotten entry cannot silently fork a visitor between teams. A carried decision beats the local native rule. Thus one visitor never sits in two worlds.
A shell that only routes to teams, and never reads the flag itself, pre-decides it on the table. The bucket then still travels:
routes(table, { visitor: session, flags: [csrMode] }); // decide-but-don't-renderThe flag manifest
Each production build writes an inventory of the flag() call sites that it compiled. The inventory is AST-resolved: renamed and namespace imports count; an unrelated local function named flag does not. It goes to:
node_modules/.ogygia/flags-manifest.json → { flags: [{ name, kind, file, line }], names: [...] }Flags rot. A code-first setup gets most of the industry flag-debt tools from the inventory alone. Record the manifest in CI. Diff it per release. Alarm on a name that lives past its retirement date. The ogygia_flags MCP tool reads it live. The retirement of a flag stays a human edit: delete the flag, keep the branch that won. The manifest tells you which flags are overdue.