# Concepts

## The native component model

A flow spec's `screens[]` array is 13 stock types (rendered by the target SDK's native UI
components — SwiftUI for an iOS host or React Native components for an RN/Expo host — including
`welcome`, `singleSelect`, `paywall`, and so on; see
[Reference → Node types](/docs/reference/#node-types)) plus one escape hatch, `custom`, for
anything those 13 don't cover. A `custom` screen names a native component the app itself
registered:

```jsonc
{ "id": "bodyStats", "type": "custom", "component": "yourapp.bodyStatsQ", "props": { "min": 120 } }
```

`component` is always `"<namespace>.<name>"` with **exactly one dot**. The namespace is a single
identifier (letters, digits, underscore) — so when your `appId` is a real dotted bundle id
(`com.greeter.demo`), that `appId` **cannot** be the namespace (it violates the one-dot rule).
Derive a namespace from it instead (`greeter` from `com.greeter.demo`, then use
`greeter.bodyStatsQ`). `props` is opaque JSON at this layer: its real shape is declared once, on
the native side, and checked against what you send at publish time (next section). There is no way
to author raw markup or a WebView slot in a v4 flow — a screen the stock vocabulary can't express
becomes a real native component, not markup.

## Custom components and the component manifest

Registering a component is two things, done together by one command:

```sh
weir components-sync --appRoot .
```

1. It reads `weir/components.json` (one entry per registered component: name, target
   platform(s), a JSON Schema for its props, the app version it first shipped in) and sends it to
   the Weir API, which assigns the next **manifest version** for your app — an integer that only
   ever goes up, the same monotonic-version discipline flow publishing uses.

```json
{
  "version": 1,
  "components": [{
    "component": "fitness.hero",
    "platforms": ["ios"],
    "minAppVersion": "1.0.0",
    "propsSchema": {
      "type": "object",
      "properties": { "headline": { "type": "string" } },
      "required": ["headline"]
    }
  }]
}
```

This is the hand-authored shape. Sync expands each `platforms` value into separate per-platform
entries in the server-side manifest. The declaration contract is exact: top-level `version` is
`1`; every component entry has `component`, `platforms`, `minAppVersion`, and
`propsSchema`. Do not add a `description` key — that is not a declaration field. For an Expo
host, `platforms` must contain `"reactNative"` (not `"ios"`):

```json
{
  "version": 1,
  "components": [{
    "component": "cardclub.welcome",
    "platforms": ["reactNative"],
    "minAppVersion": "1.0.0",
    "propsSchema": {
      "type": "object",
      "properties": { "headline": { "type": "string" } },
      "required": ["headline"],
      "additionalProperties": false
    }
  }]
}
```
2. It writes a generated registry source file for the target runtime
   (`WeirComponentRegistry.generated.swift` or `WeirComponentRegistry.generated.ts`) embedding
   that manifest version and a **registry hash** — a hash of every registered component's name and
   props schema. Commit the generated file and install it into the SDK registry before fetching.

At publish time, every `custom` screen's `component` is checked against your app's latest
synced manifest: the component must be registered for every platform the screen targets, and its
`props` must match that component's declared schema. A config naming an unregistered component,
or sending props that don't match, is refused before it ever reaches `/manifest`.

**Scope the flow to the same runtime.** A custom screen with no screen or flow platform scope is
treated as targeting every platform. Therefore, an Expo flow whose declarations say
`"platforms": ["reactNative"]` must also retain this top-level field in the flow itself:

```json
{ "platforms": ["reactNative"], "screens": [/* registered custom screens */] }
```

Otherwise the conform gate correctly asks for iOS and Android registrations too, even though this
app only delivers React Native. Keep the flow and component-manifest platforms aligned; do not
work around the error by falsely adding another runtime to `components.json`.

**The drift check.** Registering a component in Swift code and forgetting to re-sync is the one
mistake this exists to catch: the SDK compares its live registrations against the generated file
once, at first render. A mismatch emits a `health_registry_drift` event, and in a debug build,
stops with an assertion — re-run `weir components-sync` and rebuild.

Before build QA, run `weir components-sync --appRoot . --check`. This is a read-only preflight:
it makes no API request and writes nothing, but fails when `weir/components.json`, the last
synced manifest cache, or a generated registry source file has drifted. If it fails, run the
normal sync, review and commit its output, then rebuild.

**What your component receives at runtime (RFC-012 §6).** A registered custom component gets its
declared props (already validated against your `propsSchema`) plus one namespaced `weir` prop —
the seam it uses to drive the flow, since a `custom` screen has no `cta`/`next` of its own:

```ts
interface WeirCustomScreenApi {
  // Resolve this screen's next (or jump to an explicit screen id) and advance —
  // the custom-screen equivalent of a stock component's CTA press.
  submit(explicitTarget?: string): void;
  // Record screen_skipped and advance via next — for a "maybe later" affordance.
  skip(): void;
  // Set a flow variable, e.g. a quiz-like screen's answer.
  setVariable(id: string, value: JSONValue): void;
  // Record a custom interaction event in the standard event envelope.
  recordInteraction(type: string, fields?: Record<string, JSONValue>): void;
}
```

Your component renders its own CTA and calls `weir.submit()` when done. `submit("someScreenId")`
jumps (a Skip-to-last-screen affordance, for example). There is no flow-abort call from inside a
custom screen — dismissal semantics belong to the host integration, not to individual screens.

**Author, verify, and recover.** Edit `weir/components.json`, run `weir components-sync --appRoot .`
against the intended API, and commit/reference the generated registry file in each platform build.
The command reports the assigned manifest version and each platform's registry hash. Rebuild and run
the app: a debug registry-drift assertion or `health_registry_drift` means the native registrations
and generated registry differ. Correct the declarations or native registrations, sync again, rebuild,
and retry. To roll back an incompatible declaration, restore the last compatible `components.json`,
sync it, and ship an app whose registrations match that generated registry.

## Gating

Every screen, and the flow as a whole, can declare a build-compatibility floor:

```jsonc
{ "gating": { "minAppVersion": "1.4.0", "platforms": ["ios"], "fallback": "serveOlderConfig" } }
```

`fallback` says what a build that fails this screen's gate gets instead: `"skip"` drops the
screen; `"substitute"` renders a named sibling screen (which must itself carry no gating — no
chains); `"serveOlderConfig"` (the default) disqualifies the whole config version for that build,
and the server walks back to the newest version the build actually can render. A build's own SDK
version and registered-component registry hash travel with every config fetch, so an old build
requesting a config with a component it never registered gets a servable older version instead of
a config it cannot render. If no published version is servable, the SDK resolves through its
verified cached and app-embedded fallback tiers instead of attempting to draw the rejected config.

## Delivery model

> Edit a compatible flow → `weir release` → relaunch the already-installed app → the verified
> config can render natively and its event can land in `weir funnel`.

That is the v4 loop running in the controlled private alpha. “Compatible” matters: copy, props,
ordering, theme, and branches can change within the component vocabulary already in the installed
build; adding a new native component still requires an app release. Its operational evidence is
qualified: public health identifies the deployed commit and release, while the current dogfood
evidence is not a public-beta or conversion result. The loop is built from a few pieces
that each do one job:

1. **Publish** — `weir_release` sends `config.json` (the flow spec itself,
   serialized) plus any `customAssets[]` files it references to the delivery origin
   (`services/api`, `POST /publish`), which validates it (schema, then component-manifest checks
   for every `custom` screen) and updates that flow's `/manifest/:flowId` route.
2. **Sign** — the served manifest (config id, spec version, published version, the component
   manifest version it was validated against, and a sha256 per served file) is signed with an
   Ed25519 keypair. The private half lives only in deployment secrets
   (`WEIR_API_SIGNING_KEY_B64`); the paired public key is baked into the SDK build and is safe to
   distribute — e.g. via Firebase Remote Config alongside the manifest URL.
3. **Fetch, verify, gate, stage** — the installed app checks for an update on launch and on
   foreground, sends its own app version, SDK version, platform, and registry fingerprint with the
   fetch, gets back whichever config version its own build can actually render (see Gating
   above), verifies the Ed25519 signature and every file's sha256, and **stages** the result —
   never swaps a live render mid-flow.
4. **Promote** — the staged config is promoted to active only at the next flow presentation,
   never mid-flow. The render path resolves a verified promoted remote config first, then the
   app-supplied or app-embedded v4 baseline config. Source/version telemetry reports which tier
   actually rendered.

Every served file carries a lowercase SHA-256 of its exact bytes, covered by the same signature —
the SDK re-hashes each fetched file before staging and rejects a mismatch.

### Embedded baseline config

Every supported v4 host packages a real specVersion-4 config and its declared assets in the app
binary. It is native-renderer input, not HTML and not a WebView artifact. SwiftUI accepts a config
root containing either a single-flow `config.json` or `flows/<flowId>.json`; the RN/Expo config
plugin embeds a directory containing `config.json`. Custom screens work in this tier because the
native/RN component implementation ships in the same app build.

`weir embed`/`weir init` now produce a real specVersion-4 `config.json` — the native baseline
that ships in the app and that `weir_app_doctor` checks. They also produce the internal
browser-preview bundle (`index.html` plus `manifest.json`) used by `weir walk`/`weir conform`
for headless QA; that bundle is an authoring harness, not the shipped deliverable. The native
baseline (`config.json`) is what the SDK reads at runtime for its embedded fallback.

## Kill switch

The revert path for a bad remote flow **must not depend on Weir's own infrastructure being
healthy** — so the kill switch lives on Firebase Remote Config, permanently, by design, not on
anything Weir serves. A single flag (`weir_onboarding_enabled`, default `false`) plus
`weir_manifest_url` / `weir_manifest_public_key` / `weir_ingest_url` / `weir_ingest_token` route
the app's delivery and telemetry entirely through remote config the app already trusts. Flipping
that flag off returns the app to its native onboarding (`nativeFallback` in `weir/app.json`)
without needing the delivery origin to be reachable at all. The app-embedded v4 baseline config
is the offline config tier if the host chooses to present Weir while remote delivery is unavailable;
the app's separate native onboarding remains the outer fallback controlled by the kill switch.

## Experiments

The Weir API's experiment layer (sticky assignment, sample-ratio-mismatch guardrails, peeking
guardrails, a results API) is built and tested against a real Weir API, `variant_assigned` is a
first-class event in the instrumentation contract above, and the variant→config serving seam
means a published config genuinely carries per-arm content. The agent-facing lifecycle —
`weir_experiment_start`/`_status`/`_decide`/`_ship_winner` — is registered (see [Experiments](/docs/experiments/)
for the full walkthrough). Two experiments are running on this pipeline now:
`exp_cob_commitment_first` (CutOrBulk) and `exp_psd_scan_demo_first` (PSD), with 14-day horizons
ending around 2026-08-17; decide a winner only via the pre-registered rule at horizon. EXP-001, the
first experiment run on this pipeline, is closed — it validated the plumbing end to end but never
reached enough real-user traffic to power a decision (see [Experiments](/docs/experiments/) for its
closure note). No conversion-lift claim is implied by an experiment being running.
`weir_experiment_results` (a narrower, older results-only tool) stays unregistered in
`ALL_TOOLS`, superseded by `weir_experiment_status`.

## Instrumentation contract

The product's core guarantee, stated plainly: **agents get full creative freedom on presentation,
but the event stream underneath a flow is fixed and asserted, not something an agent can silently
drop or rename.** Concretely:

- The event vocabulary is closed — see [Reference → Event vocabulary](/docs/reference/#event-vocabulary)
  for the full list, and the public
  [`weir-spec/EVENTS.md`](https://github.com/cynisca/weir-spec/blob/main/EVENTS.md) for the
  full spec. `validateEventStream` checks every emitted event parses against its exact shape and
  that `seq` is monotonic starting at 0.
- `weir_conform` asserts the browser-preview artifact's emitted stream, for a scripted persona
  walk, matches what the flow spec's screens/branches/variables imply. That is authoring-time
  contract evidence; it does not substitute for native SDK/device tests.
- Every event envelope carries `{ flowId, screenId?, variantId?, sessionId, userId?, ts, seq,
  payload, screenDwellMs? }`. Device context (platform, app version, SDK version, locale, device
  class) rides once per flush batch, not once per event.
- Native appends events to a file-backed offline queue in order and owns flush policy.
  `weir_funnel`/`weir_health`/`weir_events` read the ingested result of that queue via
  `services/api`'s `/read/*` routes.
- `getResults()` (the experiment results path) dedupes conversions by `user_id`, falling back to
  `session_id` only for older clients with no host-injected user context — so `assigned` and
  `converted` are counted per user end to end, not per session.

This is why the toolkit doesn't need a visual pixel-diff or a compliance linter to make the
"agents get creative freedom" pitch safe: the freedom is scoped to presentation, and the contract
that isn't up for grabs is enforced mechanically on every build.
