# Onboard an app

This is the canonical integration checklist. Native iOS has an immutable public Swift package;
Android Compose and React Native/Expo do not have public runtime packages and remain private
design-partner paths. The public iOS tag `1.0.1` has a known telemetry defect: ingest payloads can
be rejected while the HTTP request succeeds. Use it only to reproduce rendering and delivery. A
newer fixed immutable tag must be published and pinned before production use; source-tree fixes
are not a release.

Do not use a WebView, an HTML bundle, `index.html`, or `customHtml` for a new v4 integration.
Those belong to the retired architecture.

## Choose the runtime

| Host | v4 render path | Baseline shape | Status |
|---|---|---|---|
| Native iOS | `WeirFlowView` or `Weir.present` renders SwiftUI | A resource directory with `config.json` or `flows/<flowId>.json`, plus declared assets | Public Swift package exists; `1.0.1` telemetry is release-blocked as described above |
| React Native / Expo on iOS or Android | `npm install weir-react-native` (from 1.1.0; until then, vendor) | Directory containing `config.json`, plus declared assets | Public npm package prepared at 1.1.0, publish pending the npm token. `weir init` still refuses by default until the toolkit's RN install path is updated; add the dependency by hand. |
| Native Android / Compose | Public JitPack artifacts `com.github.cynisca.weir-sdk-android:weir:1.1.0` (Compose UI) and `:weir-observe:1.1.0` (Compose-free) | v4 config plus assets | Public since 2026-09-07. `weir init` still refuses by default until the toolkit's Android install path is updated; add the JitPack dependency by hand. |

For a private runtime contract, obtain the package from Weir first, then run `./node_modules/.bin/weir init` with
`--acknowledgePrivateRuntime`. The acknowledgement does not grant or download anything. There is
no supported retired-WebView fallback.

## Install the iOS SDK immutably

In Xcode, add this exact package dependency and select the umbrella product `Weir` for the app
target:

```swift
.package(
    url: "https://github.com/cynisca/weir-sdk-ios.git",
    exact: "1.0.1"
)
// product: "Weir"
```

Do not use a branch or `from:` range for a delivery runtime. Because of the `1.0.1` telemetry
defect above, production must wait for and pin a newer fixed tag after it is publicly published
and independently resolved.

## App-owned files

Keep these source-controlled in the app repository:

- a specVersion-4 flow config;
- `weir/components.json` when the flow uses app-owned custom components;
- the generated registry source returned by a real `weir components-sync`;
- the same validated v4 config copied into the app's baseline resource directory;
- an integration file that registers components, installs the generated registry, configures
  signed updates and ingest, presents/mounts the flow, and routes every failure to the app's own
  onboarding;
- an app-owned kill switch whose local default is off.

`weir/app.json` is the repository contract used by toolkit commands. `integrationFile` belongs
inside each `flows[]` object—not at the top level:

```json
{
  "version": 1,
  "appId": "example-app",
  "appVersion": "1.0.0",
  "platform": "ios",
  "runtimeAccess": "public",
  "flows": [{
    "id": "onboarding",
    "spec": "weir/flows/onboarding.json",
    "placement": "first_launch",
    "baselineBundle": "App/Resources/Weir/Onboarding",
    "featureFlag": "weir_onboarding_enabled",
    "nativeFallback": "native_onboarding",
    "integrationFile": "App/Weir/WeirIntegration.swift"
  }],
  "delivery": {
    "manifestBaseURL": "https://delivery.example.com",
    "updateConfig": "remote-config"
  },
  "telemetry": {
    "appAnalyticsForwarding": true,
    "ingestConfiguredAtRuntime": true
  }
}
```

All paths are app-root-relative. Never put an ingest token or signing private key in this file.
Run `weir app-doctor --appRoot .` after changes. A green result means
`sourceContractReady`, not runtime or ship ready.

## Author and validate the flow

These commands operate on the v4 spec and the browser-based authoring harness:

```sh
weir dev --specPath weir/flows/onboarding.json --serve
weir walk --specPath weir/flows/onboarding.json --outDir .eval-out/walk
weir release --appRoot . --flowId onboarding --dryRun
```

For supported SwiftUI and React Native app contracts, the dry run builds the browser artifact and
runs `weir conform` internally. The preview is deliberately not the app renderer. Passing it
proves schema, branches, variables, and the expected event contract; it does not prove SwiftUI/RN
layout, native permissions, purchases, packaging, signed fetch, or fallback behavior. Use the app
doctor plus platform-native tests and a controlled device walk for those claims.

## Package the native baseline

Package the validated v4 JSON itself, not the browser preview output:

- **SwiftUI:** run `weir embed --appRoot . --flowId onboarding`, then add
  `App/Resources/Weir/Onboarding` to the app target as a folder reference. The installed app must
  contain `Weir/Onboarding/config.json` and every `customAssets[].path` file without Xcode
  flattening the directory. The adapter below resolves that directory as `configRootURL`.
- **React Native/Expo:** point the package's baseline config plugin at a directory containing
  `config.json`. The plugin copies that directory into the native application and derives
  permission declarations from the embedded flow.

`weir init` scaffolds this native baseline, `weir embed --flowId <id>` refreshes it from the app
contract, and `weir_app_doctor` checks `config.json` schema, flow identity, freshness, and
referenced asset bytes. Browser preview output remains optional visual QA; it is not the v4 native
baseline. A source-contract-ready doctor proves the repository contract is internally consistent, but a real app
build and device journey must still prove the resources were packaged and rendered.

## Purpose strings (iOS)

A `permissionPrime` screen can prime notifications, tracking, camera, or location with Weir's
built-in provider. On iOS, each relevant type has an app-owned `Info.plist` requirement only when
the app can reach that permission request:

| `permissionPrime` type | Required Info.plist key(s) | Required even if unused? |
|---|---|---|
| `notifications` | none | — |
| `tracking` | `NSUserTrackingUsageDescription` | No |
| `camera` | `NSCameraUsageDescription` | No |
| `location` | `NSLocationWhenInUseUsageDescription` | No |
| `health` | App-owned HealthKit provider and its matching purpose strings | No — Weir's default provider returns unavailable |

WeirCore does not link HealthKit. If your product needs a health prompt, inject an app-owned
`PermissionProviding` implementation, request only the HealthKit data types your product uses,
and declare the matching purpose strings in your app. `weir_app_doctor` does not infer those
app-specific requirements. Full API-to-key mapping is in the SDK README.

## Wire the runtime

### SwiftUI

Use one long-lived integration object per app process. This minimal adapter compiles against the
umbrella `Weir` product; substitute the real app id and analytics call. Runtime values come from
app-owned build settings or remote config, with safe empty and default-off values:

```swift
import SwiftUI
import Weir

struct WeirRuntimeConfig {
    var enabled = false
    var manifestURL: URL?
    var manifestPublicKey = ""
    var ingestURL: URL?
    var ingestWriteToken = ""
}

final class HostAnalyticsSink: EventSink {
    let track: (EventParams) -> Void
    init(track: @escaping (EventParams) -> Void) { self.track = track }
    func append(_ event: EventParams) { track(event) }
}

@MainActor final class WeirIntegration {
    let userId = Weir.stableUserId()
    let eventQueue = EventQueue()
    let eventSink: EventSink
    let enabled: Bool

    init(config: WeirRuntimeConfig, track: @escaping (EventParams) -> Void) {
        enabled = config.enabled
        eventSink = CompositeEventSink(eventQueue, HostAnalyticsSink(track: track))
        guard enabled else { return }
        if let url = config.manifestURL, !config.manifestPublicKey.isEmpty {
            Weir.configure(
                updates: WeirUpdateConfig(manifestURL: url,
                                          publicKeyRawBase64: config.manifestPublicKey),
                eventSink: eventQueue
            )
        }
        if let url = config.ingestURL, !config.ingestWriteToken.isEmpty {
            eventQueue.configureIngest(WeirIngestConfig(
                endpointURL: url,
                appId: "example-app",
                writeToken: config.ingestWriteToken,
                deviceContext: .current(sdkVersion: WeirSDKVersion.current)
            ))
        }
    }

    static func baselineRoot(in bundle: Bundle = .main) -> URL? {
        bundle.url(forResource: "config", withExtension: "json",
                   subdirectory: "Weir/Onboarding")?.deletingLastPathComponent()
    }
}
```

Retain it and present only while its kill switch is enabled:

```swift
WeirFlowView(
    flowId: "onboarding",
    configRootURL: WeirIntegration.baselineRoot(),
    userId: integration.userId,
    eventSink: integration.eventSink
) { result in
    switch result {
    case .completed(let variables):
        integration.eventQueue.triggerFlush()
        completeNativeOnboarding(with: variables)
    case .dismissed, .failed:
        integration.eventQueue.triggerFlush()
        showNativeOnboarding()
    }
}
```

The host must, in this order:

1. Register every app-owned SwiftUI component.
2. Install the generated manifest version/hash in `ComponentRegistry`.
3. Configure `WeirUpdateConfig` with the full per-flow public manifest URL and Ed25519 public key.
4. Create a stable user id and durable `EventQueue`; configure ingest only when a real write
   token is available from the app's secret-bearing build/runtime configuration.
5. Present `WeirFlowView` or call `Weir.present` with the baseline config root.
6. On `failed` or `dismissed`, present the app's own native onboarding.

The stable user id controls assignment; the `EventQueue` owns durable delivery; the composite
sink tees identical events to host analytics; runtime config owns updates, ingest, and the
default-off kill switch. The SDK's fetch request includes app version, SDK version, platform, generated manifest version,
and registry hash when the generated registry is installed. Never hard-code a copied SDK version
in the host.

### React Native / Expo

> **How you get the RN runtime SDK.** `npm install weir-react-native` — from 1.1.0. That
> version is prepared and publish is pending the npm token; until it lands on the registry, Weir
> vendors the built package into design-partner apps during onboarding (see `PACKAGING.md` in
> the SDK for the vendoring recipe, which stays supported afterwards). The public
> `weir-toolkit` covers everything up to this point (authoring, validation, preview, the local
> `weir dashboard` loop, `components-sync`, the release gate).

The host must:

1. Register custom RN components and call `WeirRegistry.setGeneratedRegistry(...)`.
2. Call `fetchConfig(flowId, manifestURL, { appVersion, sdkVersion: SDK_VERSION })` using the
   package's exported `SDK_VERSION`, never a host literal.
3. Mount `<WeirFlow config={verifiedConfig} ...>` with a stable user id, registry, event sink,
   purchase provider, and permission provider required by the flow.
4. Configure native durable ingest from native build metadata; do not expose its write token to
   JavaScript. For a production Expo build, set the plugin option
   <code>requireIngestToken: true</code> and provide <code>WEIR_DEMO_INGEST_TOKEN</code> only to the prebuild
   environment. The plugin fails prebuild if that required variable is absent, rather than
   shipping an artifact that only queues events. Then run <code>weir ship-ready</code> against the built
   artifact: it checks the generated <code>WeirIngestWriteToken</code> (iOS) or
   <code>weir.ingestWriteToken</code> (Android) entry without printing its value.
5. Route absent native modules, failed fetches, decode/registry errors, dismissals, and runtime
   failures to the app's existing onboarding.

Expo Go cannot load the required third-party native module. Use a development or release build.

Add the runtime's Expo config plugin before prebuilding. It embeds the validated
baseline config in the binary and supplies the pinned verify key to the native
module. Use `app.config.js` (rather than copying the key into `app.json`) so
the value always comes from the `weir.config.json` that `weir init` wrote:

```js
// app.config.js
const app = require("./app.json");
const weirConfig = require("./weir.config.json");

module.exports = {
  expo: {
    ...app.expo,
    plugins: [
      ...(app.expo.plugins ?? []),
      ["weir-react-native", {
        baselineConfigPath: "weir/config/onboarding",
        weirPublicKeyBase64: weirConfig.publicKeyBase64,
      }],
    ],
  },
};
```

For a release build, add `requireIngestToken: true` to that plugin entry and
provide `WEIR_DEMO_INGEST_TOKEN` only to the prebuild environment. The plugin
never places that token in JavaScript.

The whole host integration is one file. `weir init` already wrote `weir.config.json` with this
deployment's `manifestUrl` and pinned verify key, and `weir components-sync` already wrote
`weir/generated/WeirComponentRegistry.generated.ts` — import both rather than retyping their
values, so a re-init or a re-sync can never leave a stale literal behind:

```tsx
// weir/WeirOnboarding.tsx
import { useEffect, useState } from "react";
import { fetchConfig, WeirFlow, WeirRegistry } from "weir-react-native";

import weirConfig from "../weir.config.json";
import { WeirComponentRegistry as GENERATED_REGISTRY } from "./generated/WeirComponentRegistry.generated";
import components from "../weir/components.json";
import Welcome from "../screens/Welcome";
import PickSet from "../screens/PickSet";
import Paywall from "../screens/Paywall";

// 1. Register each native screen under the name its components.json entry
//    declares, with that entry's own propsSchema — one source, no drift.
const SCREENS = { "cardclub.welcome": Welcome, "cardclub.pickSet": PickSet, "cardclub.paywall": Paywall };
for (const entry of components.components) {
  WeirRegistry.register(entry.component, SCREENS[entry.component], entry.propsSchema);
}
WeirRegistry.setGeneratedRegistry(GENERATED_REGISTRY);

/** Renders the published flow, or `fallback` if Weir can't serve one. */
export function WeirOnboarding({ appVersion, fallback, onComplete }) {
  const [state, setState] = useState({ kind: "loading" });

  useEffect(() => {
    // 2. sdkVersion is deliberately omitted — the package sends its own.
    fetchConfig(weirConfig.flowId, weirConfig.manifestUrl, { appVersion }).then((result) =>
      setState(result.ok ? { kind: "flow", result } : { kind: "fallback", reason: result.reason }),
    );
  }, [appVersion]);

  // 3. Every failure — no native module, no network, bad signature, an
  //    unregistered component — lands here, on the onboarding already in the
  //    binary. Weir being unreachable must never block a launch.
  if (state.kind !== "flow") return state.kind === "loading" ? null : fallback;

  return (
    <WeirFlow
      config={state.result.config}
      configRevision={state.result.configRevision ?? undefined}
      onComplete={onComplete}
      onDismiss={onComplete}
    />
  );
}
```

### Make the live path actually live

After publishing, the app's normal onboarding branch must render
`<WeirOnboarding ...>`. Do **not** guard it with a literal `false` or a
permanently disabled local switch: that only proves the native fallback, never
the signed published flow. A production kill switch can choose the branch, but
its enabled value must be true for the deployment being onboarded. The
`fallback` passed above remains the recovery path for failed fetches, bad
signatures, registry failures, and render errors.

Each registered screen must also accept Weir's injected `weir` prop and use
`weir.submit()` for its next action. Keep the original `onNext` as a
default for the fallback path, for example:

```jsx
export default function Welcome({ headline, subhead, ctaLabel, weir, onNext = () => {} }) {
  const next = () => weir ? weir.submit() : onNext();
  return <Pressable onPress={next}><Text>{ctaLabel}</Text></Pressable>;
}
```

A registered screen renders the props the flow gave it, plus one extra `weir` prop — the
`WeirCustomScreenApi` above. Where the pre-Weir screen called its own `onNext`, it now calls
`weir.submit()`; nothing else about the component changes:

```jsx
export default function Welcome({ headline, subhead, ctaLabel, weir }) {
  return (
    <View>
      <Text>{headline}</Text>
      <Text>{subhead}</Text>
      <Pressable onPress={() => weir.submit()}><Text>{ctaLabel}</Text></Pressable>
    </View>
  );
}
```

Keep `onNext` as a fallback default (`onNext = () => weir?.submit()`, or the reverse) if the same
component still has to render in the app's own pre-Weir path.

## Component sync and compatibility proof

Run `weir components-sync --appRoot .` against the target app and server. Commit the generated
registry file. Then prove all five fetch dimensions from an actual app request:

| Dimension | Evidence |
|---|---|
| App version | Matches the built application's marketing version |
| SDK version | Comes from the SDK/package, not a copied host constant |
| Platform | `ios` for SwiftUI; `reactNative` for RN/Expo |
| Manifest version | Matches the generated registry source compiled into the app |
| Registry hash | Matches the server's stored copy for that exact manifest version |

Publish a config with a custom component/prop mismatch and prove the server refuses it. Request a
valid later config from an older build and prove the server serves an older compatible version or
the client falls through to its app-embedded baseline.

After publishing, `weir app-status` deliberately separates `globalLatestManifest` from
`servedForBuild`. Global latest is publication history, not evidence for a device. Pass the real
SDK version and, for custom components, the generated registry pair:

```sh
weir app-status --appRoot . --sdkVersion 1.0.1 \
  --registryManifestVersion 7 --registryHash <64-lowercase-hex-from-generated-registry>
```

The command takes app version and platform from `weir/app.json`. If any build dimension is
missing, `servedForBuild.status` is explicitly `unknown`; it never relabels global latest as
served. Use the SDK version actually compiled into the build, not the example above.

## Kill switch and telemetry

The app-owned feature flag must default to **off**. Missing config, timeout, invalid URL/key, or a
failed remote fetch must preserve the app's normal onboarding. Keep the public manifest URL/key
separate from the secret ingest write token. Flipping the flag off must return to the app's own
onboarding without contacting Weir.

Before calling the integration complete, demonstrate from a real build:

1. Embedded v4 config works offline.
2. A compatible signed remote config is verified and promoted at the next presentation boundary.
3. A corrupt signature/hash is rejected.
4. An incompatible build/registry does not render the new config.
5. Kill switch returns to app-owned onboarding.
6. A real device event reaches the read API with the expected app/SDK/flow version.

Remote delivery is presentation-boundary safe: a valid signed response is downloaded into staging,
verified, then promoted atomically. It does not replace a flow already on screen. Publish, wait for
the manifest request to complete, terminate and relaunch the test app, then begin a new onboarding
presentation and verify the served bundle id. Rollback is also a new forward version: relaunch
again before expecting the restored bytes.

For telemetry, HTTP 200 alone is not success. Inspect the ingest response and require
`accepted > 0` and `rejected === 0`; any nonzero `rejected` count is a failed integration even
when the request itself succeeded. Then use `weir events --flow onboarding` and
`weir app-status --appRoot . --sdkVersion <compiled-x.y.z>` to prove readback. Never print the
write token while collecting this evidence. Do not guess undocumented local HTTP read endpoints:
use the documented CLI commands for any additional readback.

## Safe recovery matrix

| Failure | Required user-visible result | Required evidence |
|---|---|---|
| Kill switch off or missing | App-owned native onboarding; no Weir fetch | Launch log plus device journey |
| No network / manifest timeout | Previously verified cache, otherwise embedded baseline; never blank UI | Relaunch offline and record bundle source |
| Invalid signature, hash, or malformed config | Reject candidate; keep cached/embedded config | Failure event plus unchanged active bundle |
| Build/component incompatibility | Older compatible signed version, otherwise cached/embedded baseline | `servedForBuild` query using the exact build/registry dimensions |
| Runtime decode/presentation failure | App-owned native onboarding | `.failed` handoff and fallback event |
| Ingest unavailable or rejects events | Keep events durably queued; onboarding remains usable | Queue retry plus response with `rejected === 0` after recovery |
| Bad remote release | Forward rollback, then terminate/relaunch before retest | New version id whose bytes match the requested source version |

## Package status

The official iOS repository is `https://github.com/cynisca/weir-sdk-ios.git`; its current public
immutable tag is `1.0.1` and its SwiftUI umbrella product is `Weir`. That tag has the telemetry
release blocker described above. A fixed public tag, publication verification, and an external
clean-checkout/device pass remain founder-owned release gates. Android Compose and React Native do
not have public runtime coordinates.
