[!WARNING] This repository is archived. Weft Console moved into the weft monorepo on 2026-08-30 as
packages/weft-uiand was renamed@lostgradient/weft-ui(see stevekinney/weft#926). All development, issues, and releases continue there. This repository preserves the pre-merge history; the package was never published to npm under@lostgradient/weft-console.
Operator console for the Weft durable-workflow engine —
Svelte 5 + Cinder. See
docs/implementation-plan.md for the full plan and
PROJECT-BRIEF.md for the agent-facing ground rules.
bun install
# Terminal 1: a seeded local weft server on :7233 (fixture workflows from fixtures/workflows.ts)
bun run dev:server
# Terminal 2: the Vite dev server on :5173, proxying /api, /v1, and discovery routes to :7233
bun run devOpen http://localhost:5173.
scripts/dev-server.ts boots a real, unwrapped serve() from @lostgradient/weft/server — no
hand-rolled Bun.serve(), no handleRequest() workaround, no manual /api-prefix stripping, no
reimplemented event feed. WebSocket upgrades, the per-workflow SSE/WS tail, and JSON-RPC over HTTP
(/jsonrpc) all work against bun run dev:server as a result. (An earlier @lostgradient/weft@0.11.0
bug made that impossible — serve({ engine }) threw for any root-imported Engine
(stevekinney/weft#710); fixed upstream in the historical 0.12.0 release
(#716). This package now pins 0.18.0; see scripts/dev-server.ts's module doc if you need the full
history.) The
production mount path this package exists for — serve({ dashboard: weftConsole() }) — is
runtime-verified the same way: a real serve({ engine, dashboard: weftConsole() }) instance returns
the built shell (index.html with its weft-console-config block) at 200.
A real dev harness needs a real credential, not unauthenticatedAccess — and now has one.
unauthenticatedAccess only controls whether serve() refuses to start with no auth
configured; it has no per-request effect once running, so a credential-less request always resolves
to a zero-scope anonymous principal and every access: 'scoped'/'authenticated' operation
(schedules, reviews, storage, system/registry, …) correctly 401s.
That was survivable while src/lib/scopes.svelte.ts optimistically granted every scope and degraded
on observed 403s. As of the weft 0.18.0 adoption it no longer does: resolvePrincipal() calls
weft.system.principal and reports the server's real answer, so an unauthenticated dev server would
render every scope-gated control disabled-with-reason — truthful, but useless for building those
surfaces. So bun run dev:server now configures auth with a fixed, localhost-only dev key
(scripts/dev-credentials.ts) and the Vite dev proxy attaches it to every proxied request and
WebSocket upgrade. The frozen index.html/client.ts runtime-config contract is untouched and
vite build never reaches that code, so no token can reach a build artifact. Net effect: scoped
surfaces work in the dev harness now, where they previously 401'd.
| Script | What it does |
|---|---|
bun run dev |
Vite dev server with full HMR. |
bun run dev:server |
Boots a seeded local weft server (scripts/dev-server.ts) on port 7233. |
bun run build |
Production build (Vite) → dist/, then bun run check:bundle (hard-fails on a budget miss). |
bun run check:bundle |
Gzip-measures every route/lazy chunk against scripts/check-bundle-size.ts's budgets — run standalone for a quick recheck after a build. |
bun run typecheck |
svelte-check over the whole project. |
bun run lint |
oxlint. |
bun run lint:fix |
oxlint --fix. |
bun run format |
prettier --write (Svelte + import-organizing plugins). |
bun run format:check |
prettier --check — what CI/pre-commit should run instead of format. |
bun run test |
bun test with the Svelte 5 compile plugin + happy-dom preload. |
The same built bundle (dist/) boots in three modes — distinguished only by how the shell is
served and which realtime transport is viable, never by rebuilding — via one runtime
configuration layer (plan §3). tests/deployment/ integration-tests all three modes against
real weft code: the Bun mount (tests/deployment/bun-mount.test.ts, a real
serve({ dashboard, dashboardAssets }) — all eight DASHBOARD_PAGE_ROUTES return the shell at
200, /assets/* serves the built chunks, and /api/root-stable routes are untouched), the
Service Worker mode (setupServiceWorker/handleRequest), and cross-origin (a real
serve({ cors })).
import { Engine } from '@lostgradient/weft';
import { serve } from '@lostgradient/weft/server';
import { weftConsole, weftConsoleAssets } from '@lostgradient/weft-console';
import { workflows } from './workflows';
const engine = await Engine.create({ workflows });
await serve({ engine, dashboard: weftConsole(), dashboardAssets: weftConsoleAssets() });weftConsole({ distDir? }) (src/mount.ts) returns a static Response streaming the built
index.html — serve() registers it at exactly the eight DASHBOARD_PAGE_ROUTES
(/, /workflows, /workflows/*, /reviews, /workers, /schedules, /storage, /system
as of @lostgradient/weft@0.16.0, which made the console's leaf routes real deep-linkable page
routes) and, by construction of Bun's static route table, it can never shadow /api/* or the
root-stable discovery routes (/v1/health, /openapi.json, …). The injected config block
defaults to { baseUrl: '' } (same origin — the console and API share a port under this mode).
weftConsoleAssets({ distDir? }) returns the ServeOptions.dashboardAssets descriptor
({ prefix: '/assets', directory: <distDir>/assets }) for the shell's content-hashed JS/CSS
chunks — weft 0.16.0's dashboardAssets option serves them as verified static file routes, so
the two options together are a complete deployment: no reverse proxy or separate static file
server is required. (Before weft 0.16.0, dashboard alone mounted only the page routes and
every /assets/* request 404'd; that gap is fixed upstream and
tests/deployment/bun-mount.test.ts guards the full contract against a real serve()
instance.)
Pass distDir (to both functions) only if you build once and copy dist/ to a different
location than this package's own dist/ (e.g. a CDN origin bucket) before serving it from
there. The assets directory must exist before serve() is called — weft validates it at boot.
Zero-code CLI mount (weft 0.16.0, weft#842). weft serve --console mounts this package
without writing any server code: the CLI resolves @lostgradient/weft-console from the project
it runs in, calls the exported weftConsole(), and serves the package's built dist/assets
as the asset routes — the CLI equivalent of the serve({ dashboard, dashboardAssets }) call
above. Install the console next to weft and start the server:
bun add @lostgradient/weft-console
bunx weft serve --console --workflows ./workflows.tsSecurity note. The shell HTML is served from Bun's static routes table, which is matched
before weft's fetch/auth handler runs — so serve({ auth }) protects the API, never the
page. Anyone who can reach the port gets the (empty, data-free) shell; only the API calls it then
makes are authenticated. If a deployment needs to hide the shell itself, put it behind a reverse
proxy or on a private network — weft has no page-level access control to configure.
The console runs on a different origin than the weft server — a hosted console pointed at
several backends, or local development where the dev server and API aren't same-origin. Enabled
by weft's cors option on serve():
import { Engine } from '@lostgradient/weft';
import { serve } from '@lostgradient/weft/server';
const engine = await Engine.create({ workflows });
await serve({
engine,
cors: {
allowedOrigins: ['https://console.example.com'],
credentials: true,
},
publicOrigin: 'https://api.example.com',
});The console's own config block then points at the API origin explicitly:
{ "baseUrl": "https://api.example.com" }. cors answers preflight (OPTIONS) requests, decorates
allowed-origin responses with Access-Control-Allow-* headers, and rejects cross-origin WebSocket
upgrades from disallowed origins — all verified live in tests/deployment/cross-origin.test.ts
against a real serve({ cors }) instance (preflight and actual GET for an allowed origin;
withheld headers, not a thrown request, for a disallowed one — fetch() itself never enforces
CORS, only a real browser does). Omitting cors is the safe default: no Access-Control-*
headers are ever emitted and only same-origin requests succeed; weft never falls back to
Access-Control-Allow-Origin: *.
serve() validates the cors option synchronously, before the port binds — a wildcard
origin (allowedOrigins: ['*']) combined with credentials: true, or with an explicit
Authorization allowed-header, throws at serve()-call time rather than being silently accepted
and only failing at request time. Both rejections are covered in
tests/deployment/cross-origin.test.ts. Set publicOrigin (or trustedHosts) alongside cors
so the discovery routes (/.well-known/api-catalog, /.well-known/mcp.json) emit correct
absolute URLs instead of trusting an attacker-controlled Host header.
The engine runs inside a browser (or WebExtension) Service Worker — no Bun server at all, storage
is IndexedDBStorage. A host page serves the built dist/ itself and registers the worker:
// service-worker.ts, registered by the host page
import { workflow } from '@lostgradient/weft';
import { setupServiceWorker } from '@lostgradient/weft/service-worker';
const checkout = workflow({ name: 'checkout' }).execute(async function* () {
/* … */
});
await setupServiceWorker({
pathPrefix: '/weft/',
register: (engine) => {
engine.register(checkout);
},
});The host page's index.html (copied from this package's dist/) carries a config block of
{ "baseUrl": "/weft", "eventTransport": "sse" } — WebSocket upgrades cannot be intercepted by a
Service Worker, so the console must be told to use SSE up front rather than discover it via
'auto''s WebSocket-first probe. tests/deployment/config-injection.test.ts proves this exact
block round-trips through readRuntimeConfig()/createClient() into a correctly-configured
HttpClient, and tests/deployment/service-worker.test.ts proves a REST read (GET /weft/v1/workflows/:id) resolves end to end through the real setupServiceWorker() fetch
listener against IndexedDBStorage.
Authenticated SSE through setupServiceWorker() (weft 0.16.0, weft#845).
setupServiceWorker({ handlerOptions }) accepts the ServiceWorkerHandlerOptions subset of
HandlerOptions (authContext, workflowEventFeed, fleetEventFeed,
acquireWorkflowStreamConnection), so a host that wants live feeds or a non-anonymous principal
wires them straight into the convenience wrapper — no hand-rolled fetch listener required:
import { createFleetEventFeed } from '@lostgradient/weft/server/handler';
const storage = /* the same storage instance the engine uses */;
await setupServiceWorker({
storage,
handlerOptions: {
fleetEventFeed: createFleetEventFeed(storage),
// authContext: … — required for any `scoped`/`authenticated` route
// (fleet SSE declares `events:read`).
},
register: (engine) => {
/* … */
},
});Omitting handlerOptions keeps the pre-0.16 default: every request through the Service Worker
entry point resolves to a zero-scope anonymous principal, so public REST reads work but any
scoped/authenticated operation 401s. Both sides — the anonymous default and an
authenticated, genuinely incremental fleet SSE stream (a live-appended event arriving over an
already-open Response body) — are proven through the real setupServiceWorker() fetch
listener in tests/deployment/service-worker.test.ts.
All three modes read one injected block, parsed once at boot by readRuntimeConfig()
(src/lib/config.ts) and turned into the app's single HttpClient by createClient()
(src/lib/client.ts):
<script type="application/json" id="weft-console-config">
{ "baseUrl": "/weft", "eventTransport": "sse" }
</script>| Field | Type | Meaning |
|---|---|---|
baseUrl |
string (required) |
'' → same origin (Bun mount, same-origin standalone); an absolute URL → a fixed API origin (cross-origin standalone); a root-relative path (/weft) → resolved against the page's own origin (Service Worker's pathPrefix). |
eventTransport |
'auto' | 'websocket' | 'sse' |
Optional, defaults to 'auto'. Service Worker mode must set 'sse' explicitly — WebSocket upgrades never reach a Service Worker fetch listener. |
token |
string |
Optional bearer token, held only in the constructed HttpClient (never written to localStorage or a cookie by this package). |
headers |
Record<string, string> |
Optional extra headers sent on every request (e.g. a fixed X-API-Key for a deployment that doesn't use bearer tokens). An explicit headers.Authorization wins over token. |
assetBase |
string |
Declared and validated, reserved for a future CDN asset-base rewrite (plan §3.1's "asset base URL for CDN deployments"). Not yet consumed anywhere in this bundle — setting it today has no effect. |
A present-but-malformed config block (invalid JSON, or valid JSON that fails shape validation)
throws loudly rather than silently falling back — it's generated by the mount, so a broken one is
a deployment bug worth surfacing, not masking. A missing/empty block falls back to
{ baseUrl: '', eventTransport: 'auto' } (same-origin, auto-transport) so a bare index.html
load outside any of the three documented modes still boots.
Sourced from the current operation catalog (@lostgradient/weft/src/server/operations/*.ts), not
from the scope vocabulary alone — several declared scopes are not yet enforced by any operation
(see the caveat below). What's actually checked today:
| Surface | Access policy today |
|---|---|
| Bulk workflow operations (cancel / delete / retry-failed / signal / tag-mutate) | scoped — workflows:admin |
Async-activity completion (weft.activities.complete / .fail) |
scoped — workflows:write (read/heartbeat side: workflows:read) |
Human-review listing (weft.reviews.list) |
scoped — reviews:read |
| Raw storage KV browser (get / put / delete / scan / batch / conditional-batch) | scoped — storage:admin |
| Workers, task queues, task diagnostics (list), registry, system lease, metrics | scoped — system:read |
| Worker drain / resume / clear-dead-letter, task-diagnostics admin actions | scoped — system:admin |
Per-workflow live tail (/watch, /stream) |
scoped — streams:read |
Fleet events feed (/v1/events/sse, weft.events.subscribe) |
scoped — events:read |
Schedule read (weft.schedules.get / .list) |
authenticated — any authenticated principal, no specific scope |
| Everything else — workflow reads/starts/signals/updates/queries, single-workflow control (cancel/suspend/resume/terminate), attributes, tags, schedule create/update/pause/resume/cancel, review-decision submission | public — allowed for any principal the server-level authenticator admits (including an anonymous one, if auth/unauthenticatedAccess allows it) |
A public access policy is not the same as "no authentication required". When serve({ auth })
is configured, every request still needs some valid credential — a request with none 401s before
it ever reaches the operation catalog (authenticateRequest, src/server/runtime/request-gate.ts).
public only means the operation itself performs no additional scope check once a principal is
authenticated — so a credential issued with, say, only workflows:read can still call
purge-workflows or recover-all today, because those operations never look at the principal's
granted scopes. schedules:write, signals:write, updates:write, queries:read,
attributes:read/write, tags:write, and budget:read/write are all declared in weft's scope
vocabulary but checked by no operation as of this writing. This is a known, already-tracked
posture gap in weft itself, not a console defect, and not something this repository works around
locally. The console still gates its own UI on the principal's granted scopes
(src/lib/scopes.svelte.ts's hasScope(), disable-with-reason per PROJECT-BRIEF) — that's correct
and forward-compatible with tighter server enforcement landing later, but it is a client-side
convenience, not a security boundary: don't rely on the console's disabled-button state as proof
that the server would also reject the call.
- Vite for both dev and production build — one pipeline, not a dev/prod split. Compiler-integrated
HMR via
@sveltejs/vite-plugin-svelteis the proven path for Svelte 5 (runes-aware, no full-page reloads, CSS hot-applies). Productionbun run buildrunsvite build: content-hashed assets, external sourcemaps, and one chunk per route via the dynamic imports insrc/app/routes.ts(src/routes/dashboard/cards.tsgives each dashboard card its own chunk boundary too). @tanstack/svelte-queryv5, store API (not the v6 runes API) — v5's Svelte-5 support is via its store contract (createQuery(...)returns a Svelte store; read it with$queryinside a component). The runes-native rewrite is a v6 feature (svelte: '^5.25.0'peer, a from-scratch API) and was not pulled in because the console is pinned to v5 by the scaffolding task. Verified with a real mounted smoke test (tests/tanstack-query-smoke*.svelte+.test.ts), not just a type check — and that test caught a real footgun:- Pass a plain options object,
createQuery({...})— NOTcreateQuery(() => ({...})).createQuery's parameter type isStoreOrVal<T> = T | Readable<T>: a plain value or a real Svelte store (something with.subscribe), never a callback. Passing a getter function makes the function itself the "options" value —queryKey/queryFnread asundefinedinsidecreateBaseQuery, so the query silently sits atstatus: 'pending'forever with no error and the console-logs"As of v4, queryKey needs to be an Array"warning.$query.data/$query.statusread via the$-store-subscription prefix is correct and works once options are a plain object or a real store. - For options that must react to changing component state (a filter, a page offset, an id
from the route — the common case for every list/detail route this console builds), a plain
object is captured once and never updates. The resolved bridge, used consistently across every
domain track:
toStore(() => ({ queryKey, queryFn, ... }))fromsvelte/store, wrapping a getter that reads whatever$derived/$statevalues the query depends on — e.g.src/routes/schedules/schedule-list.svelte'screateQuery(toStore(() => ({ queryKey: queryKeys.schedules.list(filter), queryFn: () => fetchScheduleList(client, filter) }))). A bare$derivedobject or a plain getter function passed directly tocreateQueryboth fail the same way the footgun above describes;toStore()is what actually satisfiescreateQuery'sStoreOrVal<T>parameter type reactively.
- Pass a plain options object,
- Bun test + a hand-rolled Svelte-compile plugin, not Vitest —
tests/setup.ts+scripts/svelte-test-plugin.tsport the provencinder/packages/componentspattern: a Bun plugin compiles.svelte(client generate) and.svelte.tsrune modules (svelte/compiler'scompileModule) before Bun's module loader sees them, happy-dom supplies DOM globals via aWindowinstance copied ontoglobalThis, and@testing-library/svelte'scleanup()runs after every test via one globalafterEach. Component tests run withbun test --conditions browser --conditions svelte— those flags are load-bearing: Cinder ships.svelte/.tssource (not justdist/) behind itsbrowser/sveltepackage.json export conditions, and without the flags Bun resolves to already-compileddist/output instead. Always runbun run test, never barebun test. This isn't Cinder-specific:svelteitself exportsmount()only behind thebrowsercondition (default/noderesolves tosvelte/src/index-server.js, an SSR-only build with nomount), so barebun testfailstests/component-harness.test.tswithlifecycle_function_unavailable: mount(...) is not available on the serverfor every component test — not a real regression, just proof the flags are load-bearing. There is no bunfig.toml or env-var equivalent for--conditions(checked: a top-levelconditionskey andBUN_CONDITIONSare both silently no-ops forbun test/bun run); the CLI flags are a hard requirement, per the comment at the top ofscripts/svelte-test-plugin.ts. - Current package baseline: Cinder v0.24.0 and
@lostgradient/weftv0.18.0. These are the exact versions pinned inpackage.json; the lockfile resolves the same versions. The historical adoption baseline was Cinder v0.19.0 and Weft v0.15.0, following the scaffolding task's historical v0.16.1/v0.12.0 pair and the implementation plan's v0.9.0/v0.11.0 authoring snapshot.lucide-svelteis pinned inside Cinder's declared peer range (>=0.400.0 <1) rather than the latest1.xline, which falls outside that peer contract. Cinder 0.17.0 finished extracting markdown/editor into standalone packages and deleted the@lostgradient/cinder/markdown/*shim — the console's one consumer (artifact-view.svelte) now imports@lostgradient/markdown/renderingdirectly, and@lostgradient/markdownis a direct dependency.
This historical adoption record covers the 0.18.0 and 0.19.0 additions that were re-evaluated against the installed component source at the time, not assumed from issue titles:
RunStepTimeline'stimed-outstatus (cinder#848, fixed by cinder#853) — adopted outright.timeline-step-state.tspreviously collapsed Weft'stimed-outtimeline status into Cinder'sfailedRunStepStatusbecause no dedicated value existed; Cinder 0.17.0 added one (danger tone, terminal), so the mapping now passes it through unchanged.InvocationRuleBuilder'smode="flat-conditions"(cinder#847, fixed by cinder#854) — fixed the rule-grouping blocker that stopped the original C4 evaluation (mode="conditions"forced every condition set under a named, moveable/removable "rule" — a shape this console's flat AND-only search-attribute grammar can't represent), but did not unblock adoption. Every mode's field selector remains a plain<select>bound to a fixedfieldOptionslist (invocation-rule-builder.svelte:635-651) with no free-text entry, and the console's query builder needs to filter on search-attribute keys it has never observed a value for yet — exactly what Cinder's ownCombobox(already used inquery-builder.svelte) provides via free-textbind:inputValue. Filed as cinder#865.src/routes/workflows/list/query-builder.ts/.sveltekeep the app-localCombobox+Select+Inputcomposition until it lands.JsonEditor(cinder#852) — evaluated againstsrc/lib/payload-editor/(the shared CodeMirror 6 editor behind Start/Signal/Update/Schedule/Storage's five payload call sites) and adopted after cinder#866 → cinder#874. Cinder 0.18.0's opt-inhighlightmode keeps the native textarea as the editing and value contract, adds lazy token highlighting and a parse position annotation, and announces parse feedback through the existingrole="status"/role="alert"wiring. Its source uses Cinder tokens in both themes and preserves accessible label/description relationships. The highlighted enhancement chunk measures 0.87 KB gzip versus the old CodeMirror chunk's 104.08 KB gzip. All five sites now use the minimalvalue/onValueChangecontract with their existing labels, rows, and external errors.- Curated Shiki (cinder#876) — the console now imports
@lostgradient/cinder/highlighters/shiki/curatedwith onlyjsonandtypescriptlanguage loaders andgithub-light/github-darktheme loaders. The old local alias shim is deleted. The fresh total JS chunk count is 353 (the previous enforced baseline was 56); the existing count gate remains enabled with a fresh measured baseline because Cinder's separate defaultCodeBlockadapter graph still emits the full registry even when highlighted call sites pass the curated adapter explicitly.
See plan §2 for the authoritative layout description. The short version:
src/mount.ts— the Bun mount export.src/main.ts/src/app/— SPA entry + shell (sidebar, topbar, command palette, notification center, toasts, scope banner — built in Phase 1, T1.6; frozen alongsidesrc/lib/**after the Phase 1 Foundation gate).src/routes/<domain>/— one directory per domain (dashboard,workflows,schedules,workers,reviews,storage,system). Each domain track owns its own directory exclusively; all seven are fully implemented (list/detail surfaces, mutations, live updates, fault/empty/loading states, colocated tests) — see plan Appendix B for the per-surface acceptance checklist.src/routes/dashboard/cards.tsis the one shared card-slot registry.src/lib/— framework-free (or.svelte.tsrune-based) modules:client.ts,config.ts,scopes.svelte.ts,router.svelte.ts,filters.ts,faults.ts,format/,live-source/. Frozen after the Phase 1 Foundation gate.src/styles/—index.css(shared entry, Cinder base styles + theme contract) plus one per-track stylesheet each domain owns exclusively (route-local rules — never added toindex.css). Component entrypoints load their own styles; only documented transitive dependencies such as chart tooltips' Popover CSS belong here.fixtures/— deterministic demo data for the dev server and integration tests, split by concern (workflows.tsis the base module + orchestratingseed();coordination.ts,saga.ts,finalizer.ts,async-activity.ts,children.ts,history.ts,tagged.ts,failures.ts,reviews.ts,schedules.tseach seed one domain-specific specimen). Append-only — see the Development section below for a tour. Playwright E2E (plan §11.4) is not yet built in this repository; there are no Playwright seeds today despite what an earlier draft of this file claimed.scripts/dev-server.ts— boots the seeded localweftserverbun run devproxies to.scripts/check-bundle-size.ts— the CI-facing bundle-size gate (plan §12), chained ontobun run build.tests/— the test harness (setup.ts) plus integration/proving tests that don't belong to a single domain track.
- Two-terminal dev harness.
bun run dev:server(port 7233) andbun run dev(port 5173) — see Quickstart above. Kill both when you're done;bun run dev:serverholds an in-memory engine with no persistence between restarts. - Test conditions flags are load-bearing. Always run
bun run test, never barebun test— it runsbun test --conditions browser --conditions svelte, and without those two flags Bun resolves both Cinder and Svelte itself to their non-browser (dist/SSR-only) export conditions, which fails every component test withlifecycle_function_unavailable. See the "Toolchain decisions" section above for the full diagnosis. - Fixture tour (
fixtures/*.ts, all started byseed()infixtures/workflows.tsagainstbun run dev:server's engine):order-processing(completes),payment-failing(always fails),long-sleeper/review-gate/signal-stepped(deliberately left running),checkout-coordination(coordination.ts—ctx.race/ctx.all/ctx.speculatein one run, for the Timeline's branch-group rendering),trip-booking-saga(saga.ts— a failing final step soctx.sagacompensates in reverse order),sandbox-session(finalizer.ts— cancelled mid-run so the finalizer sub-states render),ship-package-async(async-activity.ts— a never-externally-completedctx.completeAsync()token, for the async-completion drawer),fulfillment-parent+validate-shipment/monitor-delivery(children.ts— one awaited child, one detached long-running child),audit-trail-sweep(history.ts— 200+ durable steps, for timeline/checkpoint pagination),customer-outreach-campaign×4 (tagged.ts— varied run-level tags and search attributes), thetimeout/cancellation/resource/systemfailure demos (failures.ts— one run per failure-category taxonomy value),content-review×3 (reviews.ts— a pending sectioned/partial review, a completed decision, a timed-out review), and two schedules overinventory-sync-sweep(schedules.ts— one active every-5-minutes cron, one paused). Extend a module or add a new one for a new demo state; never mutate an existing specimen another track's tests or the dev harness itself already assert against.