Skip to content

Scenarios, personas, time and environment

A scenario answers one question: what does this app look like for this person, in this organization, after this much time, with these permissions, this much data and these conditions? This page covers how scenarios are defined and generated, how personas and permissions work, and how time, environment, links and snapshots fit around them.

API references: packages/scenarios/README.md (generator SDK, presets, integrity, timeline) and packages/runtime/README.md (controllers). Design decisions: ADR-006, 007, 008, 010 and 011 in DECISIONS.md.

The axes

Every axis is independent. A sales rep who joined last week can belong to an organization that is a year old.

AxisFieldValues
Organization age, size, plan, flagsorganizationageDays, size, subscription (free, trial, pro, enterprise, expired), featureFlags, name, type, id
Actor (who you are)actorid (persona to start as), role, joinedDaysAgo, onboardingComplete, onboardingProgress (0–1), locale
Data volumedensityempty, sparse, normal, dense, massive
Activityactivity.levelnone, low, normal, intense, dormant, reactivated
Conditionsenvironmentnetwork, latencyMs, failureMode, failureRate, failureScope, subscription, featureFlags
LookappearancethemeId, mode, reducedMotion, viewport, shell, device, orientation, layout, and the style presets radius, elevation, font, motion (see THEMES.md)
Prototype knobsgeneratorsAnything; passed untouched to your generator

The platform never decides what "dense" means. Each generator interprets density and activity for its own domain (deals, messages, transactions).

ScenarioSchema

Defined in packages/core/src/scenario.ts. Fields and defaults:

FieldRequiredDefaultNotes
idyeskebab-case; must also be listed in protoyard.json → scenarios
nameyesShown in DevTools and the gallery
seedyesNon-negative integer
referenceTimeyesISO 8601 instant: "now" for the seeded world
version1Bump when the definition changes meaningfully
description''
group'General'Groups scenarios in DevTools (Lifecycle, Roles, Billing, Conditions…)
extendsA preset id or another scenario of this prototype
organization{ id: 'org_0001', ageDays: 0, size: 1, subscription: 'free', featureFlags: {} }
actor{ id: 'user_0001', role: 'owner', joinedDaysAgo: 0, onboardingComplete: true, locale: 'en-US' }The generator must create this persona
density'normal'
activity{ level: 'normal' }
environment{}A partial patch, never a full state (see Environment)
appearance{}
generators{}
tags[]

Scenarios live in src/scenarios/index.ts. defineScenario() is an identity helper for type inference. From prototypes/crm-demo/src/scenarios/index.ts:

Blueprint-backed apps share the default scenario definitions from @protoyard/blueprint, so the player and a generated CRUD prototype begin with the same deterministic empty, active, viewer and massive worlds:

import { blueprintScenarios, parseBlueprint } from '@protoyard/blueprint';

const blueprint = parseBlueprint(JSON.parse(text));
const scenarios = blueprintScenarios(blueprint);

The helper returns data-only ScenarioDefinitions with stable seeds. The scenario engine resolves their extends references against @protoyard/scenarios presets. Add product-specific scenarios alongside these defaults when an app needs its own workflow or domain states.

const growingTeam = defineScenario({
  id: 'growing-team',
  name: 'Growing team, day 30',
  description:
    'A 12-person startup one month into using the CRM: a healthy pipeline, recent activity and an owner who set everything up.',
  group: 'Lifecycle',
  seed: 4218,
  referenceTime: REFERENCE_TIME,
  organization: { id: 'org_0001', name: 'Northwind Labs', ageDays: 30, size: 12, subscription: 'pro' },
  actor: { id: 'user_0001', role: 'owner', joinedDaysAgo: 30, onboardingComplete: true },
  density: 'normal',
  activity: { level: 'normal' },
  generators: { crm: { companies: 18, dealsPerCompany: [1, 3], winRate: 0.55 } },
  tags: ['default'],
});

Variations reuse a base with a spread. Same seed and organization, a different actor:

defineScenario({
  ...growingTeam,
  id: 'viewer-view',
  name: 'Read-only viewer',
  description: 'A finance stakeholder with view access: every create, edit and delete is blocked.',
  group: 'Roles',
  actor: { id: 'user_0004', role: 'viewer', joinedDaysAgo: 12, onboardingComplete: true },
}),

pnpm protoyard scenario list <slug> --details prints what the runtime will see:

ID              NAME                             GROUP       DENSITY  SEED  VERSION
new-company     Brand-new signup                 Lifecycle   empty    1     1
growing-team *  Growing team, day 30             Lifecycle   normal   4218  1
…

Presets, fragments and extends

@protoyard/scenarios ships domain-free building blocks.

Preset idModels
brand-new-signupEmpty workspace, onboarding not started
onboarding-in-progressOnboarding halfway (progress 0.5)
first-week7 days in, sparse data
active-monthDay-30 active user, small team
six-month-new-member180-day-old organization, actor invited today as member
power-user-year-one365 days, dense data, intense activity
dormant-accountActivity dormant
read-only-viewerRole viewer
workspace-admin / workspace-ownerRole admin / owner
subscription-free / subscription-pro / subscription-expiredBilling states
offline / slow-networkNetwork conditions
large-organization250 people, massive density, enterprise plan
quarter-of-history90 days of history with a weekly rhythm (charts, analytics)
launch-spike45 days of history and a 5× launch three days ago, still decaying
busy-weekCalendars about 90 % booked (schedule() occupancy 0.9)

Fragments each describe one axis: fragments.density, .activity, .role, .tenure, .organizationAge, .organizationSize, .accountAge, .onboarding, .subscription, .network, .featureFlags, .appearance, .tags, and for series and calendars .history(days), .seasonality(kind), .spike(daysAgo, factor, decayDays?) and .occupancy(share). Those four write knobs under generators.series and generators.schedule, read back by scenarioSeriesOptions(ctx) and schedule() (see Series, schedules, events and geo).

Two ways to use them:

// 1. In src/scenarios/index.ts: extend by id. The runtime resolves `extends`
//    against this prototype's other scenarios first, then the presets.
{ id: 'first-week', extends: 'first-week', name: 'First week', seed: 303,
  referenceTime: '2026-10-16T09:00:00.000Z' }

// 2. In code: compose and validate.
import { composeScenario, fragments, PRESETS } from '@protoyard/scenarios';
composeScenario(PRESETS['first-week'], { id: 'rep-week' }, fragments.role('member'), fragments.network('slow'));

Merging rules: objects merge key by key, arrays and scalars are replaced, undefined never overwrites. A scenario may extend the preset that shares its id. Cycles, unknown parents and duplicate ids throw a ScenarioDefinitionError naming the ids. Presets carry their own seed and reference time (2026-03-02T09:00:00.000Z), so override both to anchor the world where your prototype needs it.

The generator

generate(ctx) in src/scenarios/generators.ts returns a WorldSeed:

interface WorldSeed {
  organizations: Organization[];
  personas: Persona[];
  memberships: Membership[];
  tables: Record<string, Row[]>;   // your domain tables, by name
  events?: DomainEvent[];          // history, stored in py_events with source 'seed'
}

It receives a GenerateContext built only from the scenario:

ctx.What it is
scenarioThe resolved, validated scenario (seed already applied)
rngSeeded PRNG (mulberry32): next, int, float, chance, pick, weighted, shuffle, sample, fork(label)
fakerA private Faker instance seeded with the scenario seed, reference date set to referenceTime
idsDeterministic ids: ids.next('deal') → deal_0001, deal_0002…
timeAnchored at referenceTime: now (a Date), daysAgo(days, jitterHours?), hoursAgo, daysFromNow, between(from, to, rng.next), iso
scale(base)"How many" for the density: empty 0, sparse 0.25×, normal 1×, dense 4×, massive 25×; rounded, at least 1 unless empty

From templates/blank/src/scenarios/generators.ts:

export function generateBlankWorld(ctx: GenerateContext): WorldSeed {
  const world = identities(ctx);
  const requested = Number(ctx.scenario.generators.notes ?? ctx.scale(12));
  // …
  const notes: Note[] = ctx.rng.sample(NOTE_TOPICS, count).map(([title, body], i) => {
    const created = ctx.time.daysAgo(ctx.rng.int(1, ageDays), ctx.rng.int(0, 8));
    // …
    return { id: ctx.ids.next('note'), title, body, authorId, pinned: ctx.rng.chance(0.25),
             createdAt: ctx.time.iso(created), updatedAt: ctx.time.iso(updated) };
  });
  return { ...world, tables: { notes } };
}

generateTeam(ctx, options?) from @protoyard/scenarios builds an organization, the scenario actor and teammates with memberships, if you do not need a fixed cast. Every persona gets a seeded library avatar (av-NNN, from @protoyard/avatars). See its section in the package README.

Avatars in generators

People look real when they have faces. The library holds 175 portraits of fictional people (av-001 … av-175), and @protoyard/scenarios re-exports its seeded pickers:

import { createAvatarPool } from '@protoyard/scenarios';

// A stable fork key: these draws never shift any other seeded value.
const avatars = createAvatarPool(ctx.rng.fork('my-app:avatars:customers'), {
  avoid: team.personas.map((p) => p.avatar), // the team's faces are dealt last
});
const customers = rows.map((row) => ({ ...row, avatar: avatars.next() }));

A pool deals every face once before it reshuffles. generateTeam, generateSaasWorld, the views defaultPeople and blueprint avatar fields already do this on their own forks. Store the id (or an https:// URL or a root-relative path, per PersonaAvatarSchema), never image bytes. Render it with <Avatar name={p.name} {...avatarImage(p.avatar)} />, and initials show when there is no face. Adding faces to a generator changes its output for the same seed, so bump its generatorVersion. Provenance, rendering, sizes and re-import are in AVATARS.

Faker is the largest dependency, so the runtime loads it lazily with createGenerateContextAsync() only when a world is generated (first seed, load, reset, advance). Normal page opens never download it. Tests and Node scripts can use the synchronous createGenerateContext(scenario).

Determinism rules

The same scenario, seed, reference time and generator versions must produce a deep-equal world on every machine.

DoNever
ctx.rng, ctx.rng.fork('label')Math.random()
ctx.time.*Date.now(), new Date() without an argument
ctx.fakerThe global faker export from @faker-js/faker
ctx.ids.next(prefix)crypto.randomUUID(), createRuntimeId()
Iterate in a stable order (sort ids)Depend on object key order from external data or on Set order of runtime values
rng.fork(label) for independent streamsLet one optional loop shift every random value after it

fork(label) derives a child stream from the parent's seed and the label, so adding teammates does not change the deals generated after them.

Generator versions

Two versions are recorded with every load (ActiveScenarioInfo) and shown in DevTools:

VersionWhereBump when
generatorVersionGENERATOR_SDK_VERSION in @protoyard/scenarios (currently 3)SDK helper output changes (faker upgrade, density mapping)
prototypeGeneratorVersiongeneratorVersion in your definePrototype() (CRM: CRM_GENERATOR_VERSION = 4)Your generator would produce a different world for the same seed

Protoyard promises reproducibility for the same versions, not identical output across generator changes. The version makes the change explicit.

A stale world is rebuilt on boot. runtime.scenarios.ensureSeeded() compares the stored world's two versions with the running code. If either one differs, the runtime reloads the active scenario with its stored seed, so the world matches what the current generator builds for that seed. Adding or removing generatorVersion in definePrototype() also counts as a change. When the versions match, the stored data, edits included, is kept as before.

  • Outside a sandbox, a safety snapshot named Before refreshing <scenario> is

saved first, so earlier edits can be restored from DevTools → Snapshots. A restored snapshot is recorded with the current versions, so it is not rebuilt again on the next boot.

  • A sandbox (?sandbox=) is disposable, so it is rebuilt without a snapshot.
  • If the safety snapshot cannot be saved (for example, storage is full), the

data is kept and nothing is rebuilt.

  • runtime.scenarios.staleWorld() reports what happened, or returns null

when the versions matched. It gives the scenario, the seed, the stored and current versions, status ('reseeded' or 'kept'), the snapshotId and a readable reason. isStaleWorld(stored, current) from @protoyard/runtime runs the same comparison.

  • A ?scenario= link that differs from the rebuilt world is still offered

through pendingLink(), as before.

  • The renderer shows a dismissible notice at the top of the page whenever

staleWorld() is not null, so a rebuilt world does not look like lost edits. After a reseed it names the safety snapshot and offers Restore previous data, which calls runtime.snapshots.restore(snapshotId). When DevTools is mounted, it also points at DevTools → Snaps (Ctrl or ⌘ + .). In a sandbox it says no snapshot was saved, and when the data was kept it asks you to reset the scenario. The notice is a lazy chunk (about 1.6 KB gzip), loaded only when there is something to report; it added about 0.5 KB gzip to crm-demo's initial JavaScript (175.20 → 175.71 KB). From the console, window.__protoyard.scenarios.staleWorld() and window.__protoyard.snapshots.restore(id) do the same.

Integrity rules

Declare referential integrity in definePrototype({ integrity }). The runtime checks it on every scenario load and, by default, refuses to load a broken world (WorldIntegrityError), so a generator bug fails at authoring time. From prototypes/crm-demo/src/prototype.ts:

integrity: {
  relations: {
    companies: { ownerId: 'py_personas' },
    contacts: { companyId: 'companies', ownerId: 'py_personas' },
    deals: { companyId: 'companies', contactId: 'contacts', ownerId: 'py_personas' },
    activities: { dealId: 'deals', companyId: 'companies', contactId: 'contacts', actorId: 'py_personas' },
  },
  timestamps: {
    companies: ['createdAt'],
    contacts: ['createdAt'],
    deals: ['createdAt'],
    activities: ['at'],
  },
},
RuleMeaning
relations[table][column] = 'otherTable'Every non-null value must be an id in otherTable (array values are checked per item). Reserved tables are allowed.
timestamps[table] = [columns]Each value lies between the organization's creation and referenceTime
Always checkedUnique string ids per table, memberships point to existing personas and organizations, nobody joins before their organization exists, no identity dated after referenceTime, every generated table is declared in the schema

Check every scenario from the terminal (this runs the prototype's code in Node):

$ pnpm protoyard scenario validate crm-demo -s rep-view
✓ rep-view                 2078 rows · 11 ms

The runtime option validation: 'throw' | 'warn' | 'off' (default throw) exists for tests and debugging.

Personas, roles and policy

This is a simulation of user experience, not security. Everything runs in the browser, and anyone with the browser console can change it.

ConceptStored inNotes
Organizationpy_organizationsplan, billingStatus, featureFlags, createdAt
Personapy_personasA person you can act as: name, title, joinedAt, preferences, summary (shown in the switcher)
Membershippy_membershipsPersona × organization × role, plus optional extra capabilities

A policy maps roles to capability strings. From prototypes/crm-demo/src/domain/policy.ts:

export const policy = definePolicy({
  roles: {
    owner: ['*'],
    admin: ['*', `-${CAPABILITIES.billingManage}`],
    rep: PIPELINE,
    viewer: READ,
  },
  rules: {
    // Admins and owners pass through `*`; reps are limited to their own deals.
    [CAPABILITIES.dealsUpdate]: (principal, deal) =>
      principal.role !== 'rep' || ownsDeal(principal.persona.id, deal),
    [CAPABILITIES.dealsDelete]: (principal, deal) =>
      principal.role !== 'rep' || ownsDeal(principal.persona.id, deal),
  },
});
RuleEffect
'*'Grants every capability
'-capability'A denial. Always wins, even over * (admin above cannot manage billing).
membership.capabilitiesAdded to the role's list for that one membership; -x entries deny
rules[capability]Runs after the capability check, with the principal and the record you pass
Custom rolesAny string (rep). owner, admin, member, viewer are only a convention.
No policyDEFAULT_POLICY applies: owner: ['*'], admin: ['*', '-billing:manage'], member: ['records:read', 'records:create', 'records:update', 'records:delete:own'], viewer: ['records:read']

Enforce in two places:

  • Services call session.assert(capability, record?). It throws

PermissionDeniedError (message ends with "Simulated policy — not real security"). See DATABASE.md.

  • UI uses can(capability, record?) from @protoyard/renderer-svelte to

hide or disable controls. This is a convenience; the service check is what makes the rule hold.

Session switching

ActionAPIPersistsTouches records
Switch personasession.switchActor(personaId) (DevTools → Personas)Yes, in the control DBNo
Switch organizationsession.switchOrganization(id)YesNo
Try another roleoverrides.set({ role }) (DevTools role override)Yes, as an overrideNo
Load a scenarioscenarios.load(id)Resets actor to the scenario actor and clears overridesYes, rebuilds the world

The capabilities a principal ends up with are visible in DevTools → Personas.

Scenarios, overrides and snapshots

Three operations, never mixed up (ADR-007):

OperationAPIEffect
ALoad a scenarioscenarios.load(id, { seed?, snapshotFirst? }), scenarios.reset()Destructive, scoped to this prototype. Generate → integrity check → clear and insert in one transaction → record scenario, seed and versions → clock to referenceTime, overrides cleared, actor = scenario actor.
BOverrideoverrides.set(), session.switchActor(), theme.*, environment.set()Non-destructive. Stored in the control DB. Domain rows are never written.
CSnapshotsnapshots.save / restore / exportJson / importJsonCaptures or restores the actual world after edits: records and session together

ensureSeeded() (run on every boot) seeds only when this browser has never loaded a scenario. Reopening a prototype never resets your edits.

Every effective value reports its source, highest first:

SourceSet byCleared by
overrideDevTools toggles, link theme/mode/shell, overrides.set()DevTools Reset N, overrides.clear(), a scenario load
preferenceThe active persona's saved choice (theme.setPreference()), or persona.preferences from the generatorChanging it again
scenarioThe loaded scenario's appearance / environmentLoading another scenario
defaultprotoyard.json (defaultTheme, defaultMode, preview.viewport, preview.shell/device/orientation) or the platform default

Preferences apply to appearance only (theme, mode, reduced motion). The environment resolves as defaults → scenario → overrides.

Time

The prototype reads "now" from runtime.clock, never from Date.now().

InstantMeaning
Referencescenario.referenceTime: time zero of the seeded world
CommittedReference + every committed advance
UI time (clock.now())Committed + preview offset

Two different operations:

PreviewAdvance
APIclock.setTime(date), clock.preview({ days: 7 }), clock.clearPreview()scenarios.advance({ days: 1 })
DevToolsTime → ±1/7/30 days, or an exact instantTime → Advance 1 day / 7 days
Changes recordsNeverYes, through the prototype's timeline
Use it to seeDue dates, "3 days ago" labels, trial countdownsA world where things kept happening

Timeline: idempotent advance

definePrototype({ timeline, applyEvent }) is optional. Without a timeline, advance() only moves committed time and DevTools says so.

advance(duration):

  1. Calls timeline(ctx) with the window (committed, committed + duration].

ctx is a fresh GenerateContext for the active scenario plus from, to and a read-only read(table).

  1. Skips events whose id is already in py_events.
  2. Applies the new ones in time order with applyEvent(event, { put, get, remove }),

inside one transaction, and stores them in py_events.

  1. Commits the clock and a checkpoint. Returns { applied, skipped }.

The contract: advancing 7 × 1 day must equal 1 × 7 days, and replaying a window must apply nothing twice. Event ids make the second part work (eventId(scenarioId, at, n) → evt:<scenario>:<YYYY-MM-DD>:<n>). The first part is your job: the events for a day must not depend on the current state.

The CRM demo learned this the hard way. Its first timeline filtered deals by their current stage and decided the target stage when generating the event. After advancing one day, some deals had moved, so day 2 picked from a different list: seven single-day advances produced different events than one seven-day advance. The fix (prototypes/crm-demo/src/scenarios/generators.ts):

  • Generate from stable inputs only: every deal id, sorted, and an rng forked

per day (ctx.rng.fork('crm-day-<n>')).

  • Put the intent in the payload (advance: boolean, closeAs), not the

result.

  • Resolve it against the current record in applyEvent, and skip records

that are gone or closed. Never resurrect data.

export async function applyCrmEvent(event: DomainEvent, ctx: EventApplyContext): Promise<void> {
  if (event.type !== 'crm.follow-up') return;
  const payload = event.payload as FollowUpPayload;
  const deal = (await ctx.get('deals', payload.dealId)) as unknown as Deal | undefined;
  // Deleted or already closed deals get no follow-ups — never resurrect data.
  if (!deal || !OPEN_STAGES.includes(deal.stage)) return;
  await ctx.put('activities', { id: `activity_${event.id}`, /* … */ });
  if (!payload.advance) return;
  const next = OPEN_STAGES[OPEN_STAGES.indexOf(deal.stage) + 1] ?? payload.closeAs;
  await ctx.put('deals', { ...(deal as unknown as Row), stage: next, /* … */ });
}

Other rules:

  • ctx.rng is recreated from the scenario seed on every advance() call.

Key randomness on the day (ctx.rng.fork(label) or dayRng(ctx, isoDay)), never on how many values were drawn before.

  • Derive record ids from the event id (activity_${event.id}), so a replay

writes the same rows.

  • applyEvent runs inside an IndexedDB transaction: await only ctx calls.
  • dailyEvents(ctx, { perDay, make, hours? }) from @protoyard/scenarios

implements the window-independent pattern for you (count, instants and a per-slot rng derived from seed + day).

Series, schedules, events and geo

Data helpers for charts, calendars, activity feeds and maps. Each value is derived from the scenario id and seed plus its own key and date (or index), never from the window you ask for, so more history or more points never changes what you already had. They take { ctx } (only ctx.scenario is read) and never touch Math.random() or the wall clock; a test enforces this for the whole package.

HelperReturnsNotes
`timeSeries({ ctx, key, days \weeks \months, base, trend?, seasonality?, noise?, spikes?, min?, max?, integer? })`
eventStream({ ctx, days, ratePerDay, kinds, seasonality?, burstiness?, hours?, payload? })DomainEvent[]Poisson counts per day (bursty days at 3×), types picked by weight, built on dailyEvents (same day-and-slot ids). A kind's weight changes its share, not the event count.
schedule({ ctx, resources, window?, businessHours?, openDays?, slotMinutes?, durations?, occupancy?, recurring? })[{ id, resourceId, start, end, durationMinutes, recurring, status }]Per-resource, per-day random streams keep existing slots stable as the window grows. Never overlaps within a resource. Recurring blocks (daily, weekly, monthly) are placed first, then bookings fill free grid time; status is past, in-progress or upcoming at the reference time.
geoPoints({ ctx, region, count, key?, clusters?, spread? })[{ id, lat, lng, anchorId }]Index-keyed points cluster around weighted anchors of a fictional region (harbor-bay, north-valley, metro-grid, coral-reach), clamped to its box and rounded to 5 decimals. Every region is in open ocean; no point is a real address.
import { geoPoints, scenarioSeriesOptions, schedule, timeSeries } from '@protoyard/scenarios';

const signups = timeSeries({ ctx, key: 'signups', base: 40, trend: 'growth', ...scenarioSeriesOptions(ctx) });
const bookings = schedule({ ctx, resources: ['room-a', 'room-b'] }); // occupancy from busy-week
const depots = geoPoints({ ctx, region: 'harbor-bay', count: ctx.scale(12), key: 'depots' });

Measured on a heavily loaded machine (load average ~165): timeSeries with 10,000 daily points took 14–17 ms in the Vitest run (pnpm vitest run packages/scenarios/tests/series.test.ts -t "10k"). The test only fails above 250 ms, to tolerate slow runners.

Analytics data

@protoyard/analytics/generators builds analytics worlds on the helpers above. It uses timeSeries and dailyEvents, ctx.rng forks per stream, ctx.ids, the scenario reference time and ctx.scale, so the same seed gives byte-identical output and empty / massive density apply.

GeneratorReturnsNotes
generateTimeSeries(ctx, { key, base, days?, trend?, seasonality?, noise?, spikes?, outages? })[{ at (epoch ms), value }]timeSeries plus outage dips over whole UTC days. History, seasonality, spikes and outages default to the scenario's knobs. Integers by default (counts, cents). generateMetrics(ctx, [...]) builds several at once.
generateProductEvents(ctx, { users?, eventTypes, firstEvent?, sessionsPerUser?, properties? }){ users, events }Power-law sessions per user (a few heavy users, a long tail), weighted event types, quiet weekends when weekly, user properties on every event. AnalyticsEvent rows, sorted by time.
generateFunnelJourneys(ctx, { steps, conversion, users?, medianStepMinutes? })AnalyticsEvent[]Ordered journeys that continue with the given chance per step and lognormal gaps; computeFunnel recovers the rates.
generateCohorts(ctx, { cohorts?, interval?, cohortSize?, retention? })AnalyticsEvent[]Signups per cohort and returns that follow retention (a list, or { initial, decay }); computeRetention recovers the curve.
liveCounterEvents(ctx, { counter, perDay, maxDelta?, from?, to? })DomainEvent[]Timeline events for live counters, built on dailyEvents: advancing seven days at once equals seven single days, with the same ids.

Fragments in analyticsFragments (combine with composeScenario or extends):

FragmentEffect
launchSpike(daysAgo = 14, factor = 4)Spike that decays over about 4 days, tagged launch
outageDip(daysAgo = 10, days = 1, depth = 0.15)Traffic falls to depth of normal (generators.analytics.outages), tagged outage
seasonal()Weekly and annual rhythm over 180 days of history
empty() / massive()Density empty (no users) or massive (about 25×)
import { analyticsFragments, generateMetrics, generateProductEvents } from '@protoyard/analytics/generators';

const metrics = generateMetrics(ctx, [{ key: 'signups', base: 120 }, { key: 'revenue_cents', base: 450_000 }]);
const { users, events } = generateProductEvents(ctx, {
  eventTypes: { page_view: 6, feature_used: 3, export: 1 },
  firstEvent: 'signup',
  properties: { plan: [['free', 6], ['pro', 3], ['team', 1]] },
});

Bump ANALYTICS_GENERATOR_VERSION (and your prototype's generatorVersion) when output can change. Measured on the Apple M1 Max under heavy load (load average ~110): 90 days × 10 metrics plus about 19k events from 2,000 users took a median of 26–33 ms over three runs (PROTOYARD_BENCH=1 pnpm vitest run packages/analytics/tests/generators.test.ts, which asserts < 50 ms). Formatting timestamps with an arithmetic ISO formatter (isoString) instead of Date#toISOString cut this from 120–230 ms.

Environment

runtime.environment simulates conditions for service calls. It only affects work wrapped in environment.run(name, fn). It does not intercept fetch or any other browser networking (ADR-011).

FieldValuesEffect inside run()
networkonline, slow, offlineslow adds 1200 ms; offline throws SimulatedServiceError('offline')
latencyMs0–30000Added to every call
failureModenone, timeout, server-error, permission-denied, rate-limitedThrows SimulatedServiceError with that code
failureRate0–1 (default 1)Share of calls that fail
failureScopeOperation-name prefixesEmpty = every operation; ['agents.'] = only those
subscription, featureFlagsRead by your code (runtime.environmentState(), environment.isFeatureEnabled(flag))

Failures are reproducible: whether call n of an operation fails is derived from the operation name and the call count, not from Math.random().

Name operations <area>.<action> (deals.create, agents.atlas) so scopes can target them. The AI chat demo's partial outage (prototypes/ai-chat-demo/src/scenarios/index.ts) breaks only agent calls while chat keeps working:

environment: { failureMode: 'server-error', failureRate: 1, failureScope: ['agents.'] },

Scenario environment, overrides and snapshots all store a patch (EnvironmentPatchSchema), never a full state. { latencyMs: 250 } changes latency and nothing else. The effective state is defaults → scenario environment (plus the scenario organization's subscription and flags) → overrides. DevTools → Env writes overrides, so clearing them restores the scenario's conditions.

runService() in the renderer turns SimulatedServiceError into a toast ("You are offline", "Server error"…). Show honest states: a failed save must not look saved.

A link carries scenario coordinates, never data:

http://localhost:5173/?scenario=rep-view&actor=user_0003&seed=42&theme=ocean&mode=dark&devtools=0#/pipeline
ParamAcceptedBehavior
scenariokebab-case idFirst visit in this browser: seeds that scenario (an unknown id falls back to the default scenario). Existing world that differs: nothing is replaced; a "Load linked scenario" prompt appears (with "snapshot first" ticked), or explains that the id is unknown.
seed1–10 digitsUsed with scenario (first seed or the prompt)
actor[A-Za-z0-9_-]{1,64}Switches to that persona if it exists; ignored otherwise
themekebab-case idApplied as a visible, clearable override if the theme exists
modelight, dark, systemApplied as an override
shellauto, none, phone, tablet, desktop-window, browser, widget-host, extension-popup, side-panelApplied as a visible, clearable override; the runtime reports it as appearance().shell with source override
layouta layout id the prototype supports (preview.layout)Applied as a visible, clearable override; unsupported ids are ignored
radius, elevation, font, motionthe style preset ids (RADIUS_PRESET_IDS, ELEVATION_STYLES, FONT_PAIRING_IDS, MOTION_PRESET_IDS)Applied as visible, clearable style overrides (THEMES)
devtools0 or 10 hides the panel (presentation mode). DevTools can only appear if the build includes them.

Invalid values are ignored, not reported. DevTools → Scenario → Copy link builds a link with scenario, plus actor and seed only when they differ from the scenario's defaults, and any active look override (theme, mode, shell, layout, radius, elevation, font, motion), replacing stale values in the page URL. Share → presentation link adds devtools=0.

Edits are never in a URL. To share a world with edits, share a snapshot.

Sandboxed worlds

Add ?sandbox=<name> to any prototype URL to run it in a disposable world with its own databases (protoyard.<namespace>~<name>.{data,control}). The default world and other sandboxes are never touched. That means two iframes, for example ?sandbox=a&scenario=new-company and ?sandbox=b&scenario=enterprise-scale, can compare scenarios side by side on one origin.

A sandbox follows its link's scenario directly, without the "replace your edits?" prompt, and keeps your edits across plain reloads. Discard it from DevTools or with runtime.discardSandbox(). listProtoyardDatabases(namespace) in @protoyard/database lists the sandboxes left over for cleanup. Sandbox names must match [a-z0-9][a-z0-9-]{0,23}; anything else is ignored and the default world opens.

Compare scenarios side by side. Open a prototype in the gallery and choose Compare scenarios (/p/<slug>/compare/?a=<id>&b=<id>). Each frame loads the same build with ?sandbox=compare-<a|b|c>, so it gets its own database pair (protoyard.<namespace>~<sandbox>.*). Edits never reach other frames or the prototype's normal data. Reset sandboxes re-seeds every frame from its scenario by moving to fresh names (compare-a-1, compare-a-2, …).

Limits: the old sandbox databases stay in the browser until you clear them (DevTools → Data → Clear other sandboxes, or listProtoyardDatabases). In the local dev gallery, frames point at a running dev server (start it from the page or with pnpm protoyard dev <slug>). A static gallery without embedded builds only shows the commands.

Snapshots

A snapshot is the actual current world: every domain and reserved table, plus the scenario, seed, actor, organization, clock, overrides, effective environment and appearance. It reproduces edits no generator could.

Snapshots are JSON, so uploaded file contents (ArrayBuffer or Blob fields) are not included: their rows keep the metadata and the binary field is null. Reading such a file after a restore reports that its contents are missing (DevTools → Snaps says so next to the Save button).

ActionDevTools → SnapsAPI
Save (name + notes)Savesnapshots.save({ name, notes })
RestoreRestoresnapshots.restore(id)
DownloadDownload / Export current worldsnapshots.exportJson(id?)
ImportChoose file → preview → Import or Import & restorepreviewImport(text), importJson(text, { restore })

Restoring writes all records in one transaction (a failure leaves the current data untouched), then restores the scenario, actor, organization, clock and overrides. A running clock resumes from the current time.

File format

<slug>-<name>-<YYYY-MM-DD>.protoyard.json, for example crm-demo-before-demo-2026-10-17.protoyard.json:

{
  "format": "protoyard.snapshot",
  "formatVersion": 1,
  "id": "snap_u…",
  "name": "Before demo",
  "notes": "",
  "prototypeId": "example.crm",
  "prototypeVersion": "0.1.0",
  "schemaVersion": 2,
  "scenarioId": "growing-team", "scenarioVersion": 1, "seed": 4218,
  "referenceTime": "2026-10-16T10:00:00-05:00",
  "createdAt": "…",
  "actorId": "user_0001", "organizationId": "org_0001",
  "clock": { "referenceTime": "…", "committedOffsetMs": 0, "previewOffsetMs": 0, "running": false },
  "environment": { "network": "online", "…": "…" },
  "overrides": {},
  "appearance": { "themeId": "violet", "mode": "system" },
  "tables": [{ "name": "deals", "rows": [] }],
  "stats": { "deals": 0 }
}

Import validation

Snapshot files are data: they are validated with SnapshotSchema and never executed.

CaseResult
Larger than 25 MBRejected
Not JSON, or not the schemaRejected with the first problem (path — message)
Different prototypeIdRejected: "This snapshot belongs to "x", not "y". Nothing was changed."
schemaVersion newer than the prototypeRejected: update the prototype first
schemaVersion olderAccepted with a warning. Rows are restored as they are; no migration runs on them, so make sure old rows still read correctly.
Unknown tablesSkipped, with a warning
Scenario no longer existsRecords restored without an active scenario, with a note

Importing the same file twice creates two stored snapshots (each import gets a fresh id).

Sharing snapshots

  • Files work across origins: export from localhost, import into a

published build of the same prototype. Only the prototypeId (the manifest id) and schema version must fit.

  • Commit shared snapshots as

prototypes/<slug>/snapshots/<name>.protoyard.json. pnpm protoyard snapshot list <slug> validates them.

  • Snapshots saved in DevTools live in that browser's control database and

count against its storage quota.

Write your own scenario

A walkthrough on a prototype created from the blank template (pnpm protoyard new my-idea --template blank). Goal: the "active" world on a slow network.

1. Add the scenario to src/scenarios/index.ts. extends: 'active' reuses the organization, actor, density and data knobs of the existing scenario. The ScenarioInput type still requires seed and referenceTime on every entry, so repeat them; the same seed gives the same world:

{
  id: 'busy-week',
  extends: 'active',
  name: 'Active owner on a slow network',
  description: 'The "active" world, but every save takes ~1.5 s.',
  group: 'Conditions',
  seed: 202,
  referenceTime,
  environment: { network: 'slow', latencyMs: 300 },
  tags: ['conditions'],
},

2. List the id in protoyard.json:

"scenarios": ["empty", "active", "viewer", "busy-week"]

definePrototype() throws at startup if a scenario is missing from this list.

3. Teach the generator any new knob (optional). This scenario needs none. If you add generators: { notes: 30 }, read it in generators.ts with a fallback (ctx.scenario.generators.notes ?? ctx.scale(12)).

4. Validate:

pnpm protoyard validate my-idea                # ids match, schema version, types
pnpm protoyard scenario validate my-idea -s busy-week

5. Load it: DevTools → Scenario → Active owner on a slow network → load, or open ?scenario=busy-week in a fresh browser profile. Create a note and watch the 1.5 s save (300 ms + 1200 ms for slow).

6. Test it. Generators should be deterministic and integrity-clean for every scenario. The template's test (tests/blank.test.ts) loops over definition.scenarios with parseScenario(input), which does not resolve extends: with busy-week added it fails on the missing seed. Resolve the list the way the runtime does instead:

import { createGenerateContext, PRESETS, resolveScenarios, validateWorld } from '@protoyard/scenarios';

for (const s of resolveScenarios(definition.scenarios, PRESETS)) {
  const a = generateBlankWorld(createGenerateContext(s));
  const b = generateBlankWorld(createGenerateContext(s));
  expect(a).toEqual(b);
  expect(validateWorld(a, { ...definition.integrity, referenceTime: s.referenceTime })).toEqual([]);
}

Run pnpm vitest run prototypes/my-idea.

A checklist for a good scenario:

  • [ ] The id says the situation (trial-expired, not scenario-4).
  • [ ] The description says what to look at ("new records are paywalled").
  • [ ] The actor persona exists in the generated world.
  • [ ] It changes one or two axes from a sibling, so differences are easy to

read.

  • [ ] Empty, loading and error states are reachable through some scenario.