Philosophy

Static core. Flux implementations.

A small, stable, versioned core of contracts — and everything else as plugins. Ten principles keep it that way, and tooling keeps the principles honest.

StaticFlux is a client-side TypeScript framework for large single-page applications that span many teams, many packages and possibly many repositories. Its name is its design: a static core of contracts and primitives that rarely changes, and flux — everything else — as implementations that plug into it.

The core never knows about features. Features, and the framework’s own packages, contribute through the same typed contribution points. That one decision — inversion of control everywhere — is where most of the rest follows from.

The ten principles

Every package is held to these. They are stated in docs/REQUIREMENTS.md §1 and enforced, wherever a machine can enforce them, by types, lint rules, the dependency graph tool and CI budgets.

  1. P1

    Type-safe end to end

    Misconfiguration is a compile-time or CI error, not a runtime surprise. Tokens, flags, routes and capabilities are typed values; declaration merging is the cross-package extension mechanism.

    Why: A string key that drifts silently is the most common bug in a large app. A typed value that drifts fails the build — and tells you where.

  2. P2

    Declare, don’t wire

    Every logical unit declares what it requires and what it provides. Manifests are data, checked by types and by tooling.

    Why: When dependencies are data, the graph can be printed, checked for cycles and scope leaks, ordered for boot, split into chunks — by a machine, before anyone runs the app.

  3. P3

    Granular units

    No god objects. A unit does one thing; fan-in and fan-out are measured and budgeted.

    Why: Small units are testable alone, lazy-loadable alone and replaceable alone. The graph tool flags the unit that starts to absorb everything.

  4. P4

    Composition, not inheritance; plugins, not branches

    Experiments and variants are alternate bindings, never if/else in feature code. Inheritance depth is at most one (Lit’s base element).

    Why: Branches multiply: every flag doubles the paths through a feature. Bindings replace instead of multiply — each variant is a unit you can test and delete.

  5. P5

    Everything observable

    Every unit, transition, resolution, request and decision emits telemetry through one vendor-neutral fabric. Devtools are a consumer of that fabric.

    Why: You cannot fix a boot regression you cannot see. With one fabric, the boot waterfall, the journey trace and the gate explanation come for free.

  6. P6

    Everything cancellable and disposable

    Every async operation takes an AbortSignal; every resource registers in a lifecycle-owned disposal scope.

    Why: Leaks and late writes are lifecycle bugs. When every listener, timer and request belongs to a scope, leaving the scope is the cleanup.

  7. P7

    Budgets enforced from day one

    Bundle size, chunk size, boot time, activation time, memory and dependency fan-out are declared in manifests, enforced in CI and surfaced in devtools.

    Why: Performance is lost one reasonable commit at a time. A budget that fails CI — and ratchets tighter when you beat it — keeps the wins you already paid for.

  8. P8

    Shallow abstractions

    At most one layer between a contract and its implementation.

    Why: Every indirection is a place to get lost. One hop from interface to code means a reader — or an agent — can always find what actually runs.

  9. P9

    One version per app

    Every framework package is a singleton per app; no duplicate versions, including across federated modules.

    Why: Two copies of the injector or the reactivity runtime break identity checks in subtle ways. The build validates shared singletons before anything ships.

  10. P10

    Accessible, localizable, testable by default

    These are base requirements, not add-ons.

    Why: Retrofitting focus management, RTL or test seams is expensive; designing them into the primitives makes the right thing the default thing.

Inversion of control

In most apps, the app reaches out for what it needs: it imports a singleton, reads a global, calls a service locator. In StaticFlux, nothing reaches out. A unit declares its dependencies and the injector hands them in. The router doesn’t know about auth — auth contributes a stage plugin. The shell doesn’t know about features — features contribute slot items. The gate doesn’t know where facts come from — fact sources are plugins.

The payoff is that every package stays small and replaceable, and every integration is visible in one place: the composition. In the Studio example app, src/app/compose.ts is the single file where packages meet, and each pair of packages that needs to talk does so through a tiny adapter file.

apps/studio/src/adapters/auth-runtime.ts (abridged)
// auth never imports runtime. It exposes a listener port; the app contributes to it.
export const epochRefreshListenerUnit = contribution({
  id: 'studio/epoch-refresh-listener-unit',
  provides: SessionListenersToken,          // a multi token owned by @staticflux/auth
  scope: 'app',
  deps: { lazy: { supervisor: SupervisorToken } },  // owned by @staticflux/runtime
  create: ({ supervisor }) => ({
    id: 'studio/epoch-refresh',
    onSessionChange(change) {
      if (change.requiresEpochRefresh && supervisor.value?.can('refresh') === true) void supervisor.value.refresh();
    },
  }),
});

Composition over inheritance

StaticFlux has exactly one base class you extend: SfElement, because Lit requires a class. Everything else is composed. A feature is a manifest plus a module of hooks. A unit is a plain object with a create function. A variant is a binding. A middleware returns the same interface it wraps — namespaced, withTtl, encrypted and tiered storage all stack because each one is an AsyncStore.

The inheritance-depth lint rule fails any chain deeper than one level, so this is not a style preference that erodes — it is a property of the codebase.

Performance first

Everything is lazy by default, framework packages included. Loading eagerly is an explicit declaration with a mandatory reason, counted against the boot budget and shown in the boot waterfall. Critical units are the only ones boot waits for; a tiny inline mode detector picks a per-profile boot plan before anything else runs, so code for other modes is never fetched.

Rendering is snappy by construction: one render pass per frame, fine-grained reactivity, measure/mutate phases separated by the scheduler, size-stable placeholders, heavy work in workers. And StaticFlux defines its own performance model — journeys and feature marks measured by the framework — instead of relying on Web Vitals as the source of truth.

An agent-first codebase

The codebase is written to be read by humans and AI agents without either having to assume anything. That means: explicit over clever, verbose over magic, shallow abstractions, no magic strings, and rules enforced by tooling rather than by tribal knowledge.

Every package ships an AGENTS.md (purpose, layer, public API by file, rules, budgets, test command), with generated sections kept in sync with the config CI enforces. Every error is defined once with defineError — a stable code like SF-NAV-002, a kind, a summary and a concrete fix — and collected into the machine-readable catalog docs/errors.json. Agents look codes up instead of guessing.

errors are values, with a fix
export const ScopeViolation = defineError('SF-DI-002', 'invariant',
  'A longer-lived unit requires a shorter-lived unit.',
  'Move the dependency to a lazy dep, or lower the consumer scope.');

return err(ScopeViolation({ units: [consumer.id, dependency.id] }));

Enforcement over convention

A rule that is only written down is a suggestion. StaticFlux turns its rules into checks that run in pnpm check and in CI:

RuleEnforced by
No deep imports, no runtime cycles, core imports nothing internal, features never import featurespnpm lint:boundaries (dependency-cruiser)
Layer boundaries: a package imports only what it declares, from lower layerspackage.json dependencies + staticflux.layer, documented by sf agents
No direct flag reads, no bare addEventListener, inheritance depth ≤ 1, no string tokens, no default exportspnpm lint:sf (the sf CLI)
Missing providers, duplicate dependency keys, typed tokens / routes / URL keysTypeScript (composeUnits, defineModule, declaration merging)
No cycles, scope rules, fan-in / fan-out, read-but-never-informed metricssf graph
Every error code unique and cataloguedpnpm errors:check
Bundle budgets per package and per logical chunkpnpm size, the Vite / Rspack plugins
Route × mode completeness, valid build manifestssf routes, sf manifest
Every implementation of a port passes its shared contract suite, on every medium and topology@staticflux/testkit contract suites in pnpm test
No denied or unknown licenses across npm, cargo and model weightspnpm licenses:check

Client gating is UX, not security

StaticFlux has a fast, expressive gating system: facts, declarative policies, compiled decision tables, explainable decisions, meters for credits and quotas. It decides what to render, which chunks to load (a denied feature’s code is never even fetched) and which calls to attempt.

It is not authorization. Anything that runs in the browser can be changed by the person using it. Every server endpoint re-checks every request. Every package that gates — gate, auth, capabilities, webmcp, sandbox, router, loader — says so in its AGENTS.md, and advisors (the intelligence layer) are never consulted for access decisions at all.

Where to go next

AGENTS.md on GitHub: @staticflux/core · @staticflux/runtime · @staticflux/gate