# Upgrade from 0.x to 1.0 (/docs/reference/upgrade-to-v1)



This page is the migration path from `@domino-sdk/relay` and `@domino-sdk/relay-cli` 0.x to 1.0. Every breaking change a 0.x project meets has its own section under [Changes](#changes). Features that 1.0 added without breaking anything are in their own guides, and changes made during 1.x are in the [changelog](/docs/reference/changelog).

## Who needs this [#who-needs-this]

Projects whose `package.json` depends on `@domino-sdk/relay` or `@domino-sdk/relay-cli` at a 0.x version. Run `domino doctor --json` to read the installed versions; [Versions and compatibility](/docs/reference/versions) lists the versions this documentation revision describes.

The two packages are released together and share one version, so upgrade them together and never mix a 1.x SDK with a 0.x CLI, or the reverse.

## What 1.0 is [#what-10-is]

1.0.0 was released on 2026-10-01, and the npm `latest` dist-tag carries 1.x, as [Release channels](/docs/reference/versions#release-channels) describes. Before it, 1.0 shipped as `1.0.0-next.N` prereleases, which could break between prereleases. This page compares 0.x with 1.0 directly.

## Supported consumers and compatibility [#supported-consumers-and-compatibility]

Use Node.js 24 for the CLI, server and build tools, TypeScript 5.5 or later for typed consumers, and React 19 for the optional React entrypoint. The base client, browser helpers and server client do not require React.

Stable 1.x APIs follow semver: a breaking change requires a new major version. Only explicitly experimental entrypoints, names or declarations have experimental compatibility rules, and an `/internal` entrypoint is unsupported for application code. Keep the SDK and CLI pinned to the same exact version.

Minor releases only add, but an addition can still stop code that builds SDK types itself from compiling. A `@compat` tag in a type's doc comment says how the type may grow:

| Tag              | Types                                                                | A minor release can                                                                   |
| ---------------- | -------------------------------------------------------------------- | ------------------------------------------------------------------------------------- |
| `@compat sealed` | `RelayClient`, `ManagementClient`, `RequestError` and its subclasses | Add methods and fields. Only the SDK creates these; don't implement or subclass them. |
| `@compat output` | Results such as `Offer`                                              | Add fields. An object you build as this type, such as a test fixture, may need them.  |
| `@compat open`   | The `Known*` unions, such as `KnownRelayErrorCode`                   | Add values. Give a `switch` over them a default branch.                               |
| `@compat input`  | Options such as `RelayClientOptions`                                 | Add optional fields or accept more values.                                            |

## Upgrade [#upgrade]

### 1. Install the latest release [#1-install-the-latest-release]

Install both packages from `latest` at the same exact version, with your project's package manager. With npm:

```sh
npm install --save-exact @domino-sdk/relay@latest @domino-sdk/relay-cli@latest
```

With pnpm:

```sh
pnpm add -E @domino-sdk/relay@latest @domino-sdk/relay-cli@latest
```

Replace any `file:.domino/*.tgz` entries and pnpm overrides first, as [the 0.x upgrade procedure](/docs/reference/troubleshooting#check-for-an-sdk-upgrade) describes. Commit the regenerated lockfile.

### 2. Build and typecheck [#2-build-and-typecheck]

Run these from the project directory:

```sh
npx domino build
npm run check
```

`domino build` bundles your quests, engagement rules, reusable types, and collection slots without publishing. `npm run check` is the typecheck script the starters ship (`tsc --noEmit`); use your project's own typecheck if it differs. Treat every error as an entry in your to-do list.

### 3. Follow each section [#3-follow-each-section]

Open each section under [Changes](#changes), search for the affected code, and migrate it. Run the two commands above again after every few sections.

### 4. Verify before publishing [#4-verify-before-publishing]

Run `npx domino doctor --json`, then `npx domino deploy --preview --environment test --json` to validate the project against the server without publishing. If an error matches no section here, report it with `domino feedback`.

### 5. Reach live through a promotion [#5-reach-live-through-a-promotion]

Two server rules changed during 1.x, so a project upgrading from 0.x meets them on its first deploy to live, whatever its SDK version. Since 1.34.0, live refuses every definition write, such as creating or renaming a point ledger, from every credential. Since 1.40.0, live refuses direct code deployments: `domino deploy --environment live`, with or without `--preview`, and `management.deployments.preview` with a client for live. `domino deploy --promote TEST_DEPLOYMENT_ID --environment live` still prepares a promotion, but it is deprecated in favor of `domino promote`. Both answer 422 `promotion-required`, which the CLI reports as `ENVIRONMENT_UNSUPPORTED` with exit 1. Deploy to test, run `domino promote`, and have a person approve the promotion in Console. See [test and live](/docs/concepts/environments), [promotion](/docs/concepts/promotion), and the 1.34.0 and 1.40.0 entries of the [changelog](/docs/reference/changelog).

## How each change is written [#how-each-change-is-written]

Every breaking change has one section with the same five parts, so an agent can apply it without reading the rest of the page:

| Part         | What it gives you                                                                            |
| ------------ | -------------------------------------------------------------------------------------------- |
| What changed | The removed or renamed API, option, command or response field, and the new behavior.         |
| Old          | The 0.x code, usually in a `text` block because it no longer compiles.                       |
| New          | The same code for 1.0, in a `ts` block that the documentation build typechecks.              |
| Why          | The reason for the break, so you can judge edge cases the example does not cover.            |
| Find it      | A [ripgrep](https://github.com/BurntSushi/ripgrep) command that lists every place to change. |

New examples declare the handles they use, such as `declare const community: LedgerHandle`; in your project, import the handles you already have.

## Changes [#changes]

### Primitive handles [#primitive-handles]

Authoring constructors take `key` and return typed handles, and helpers that connect authored resources accept those handles instead of raw keys or `{ key }` objects. The `id` alias on constructors is removed, and budget, tier, referral milestone and referral bonus declarations use `key` too. The rule covers `completed`, `questCompleted`, `balanceAtLeast`, `inGroup`, `points`, `budget` ledger costs, leaderboard and referral ledger, group and quest references, engagement rules, and hosted `TrackerReader` methods. `defineTieredReward(...).tier()` accepts only a tier key the definition declares. A ledger an operator selects in Console is `settings.pointLedger({ label, default? })`, whose value `points` and ledger costs accept; without a default, an empty selection pauses the quest, so guard the rewards.

Old:

```text
const reward = points("community", 100)
balance: settings.resource({ source: pointLedgers(), label: "Point ledger" })

export default defineLeaderboard({
  id: "weekly",
  title: "Weekly contributors",
  balance: "community",
  score: { kind: "earned", quests: ["welcome"] },
})
```

New, in `resources.ts`:

```ts
import {
  defineLeaderboard, defineLedger, earned, points, settings, type QuestHandle,
} from "@domino-sdk/relay/authoring"

declare const welcome: QuestHandle

export const community = defineLedger({ key: "community", name: "XP" })
export const reward = points(community, 100)
export const ledgerSetting = settings.pointLedger({ label: "Point ledger" })

export const weekly = defineLeaderboard({
  key: "weekly",
  title: "Weekly contributors",
  ledger: community,
  score: earned({ quests: [welcome] }),
})
```

Publish the named exports in `relay.json`, such as `"pointLedgers": [{ "entry": "resources.ts", "exportName": "community" }]`, and keep existing keys when moving definitions between files. Handles exist only in source: management methods and API reads keep their string IDs, so don't wrap a returned ID in `{ key }` or cast it to a handle.

`domino migrate` previews the syntax rewrites and `domino migrate --write` applies them: `id` becomes `key`, also on nested declarations, and `balance` becomes `ledger`. It reports each string `quests`, `qualifiedBy`, `ledger` and prerequisite reference as `HANDLE_REFERENCE_REQUIRED`; replace those with imports, including in code generators.

Why: a raw string could connect a quest to the wrong kind of resource, and `id` confused an authoring key with the ID Relay stores. Publication still serializes the stable key.

Find it: `rg -n -e 'points\(' -e 'completed\(' -e 'questCompleted\(' -e 'balanceAtLeast\(' -e 'settings\.resource' -e '\bid:' -e '\bbalance:' -e '\bqualifiedBy:' -e '\bquests:' --glob '!node_modules/**'`

### Prerequisites are typed eligibility rules [#prerequisites-are-typed-eligibility-rules]

`completed(quest)` returns `{ kind: "completion", quest, scope: "ever" }` instead of `{ quest }`, and `prerequisiteSchema` no longer accepts the old shape, so republish quests built with 0.x. `completed(quest, { scope: "period", period })` checks a project-calendar period, which must be named. `balanceAtLeast(ledger, minimum)` and `inGroup(group)` are the other rules. Publish any definition you add: `requires` refers to it but does not register it in `relay.json`.

Old:

```text
const requirement = { quest: "welcome" }
if (requirement.quest === "welcome") { /* ... */ }
```

New:

```ts
import {
  balanceAtLeast, completed, inGroup, type GroupHandle, type LedgerHandle, type QuestHandle,
} from "@domino-sdk/relay/authoring"

declare const welcome: QuestHandle
declare const dailyCheckIn: QuestHandle
declare const community: LedgerHandle
declare const veterans: GroupHandle

export const requires = [
  completed(welcome),
  completed(dailyCheckIn, { scope: "period", period: "day" }),
  balanceAtLeast(community, 100),
  inGroup(veterans),
]

for (const requirement of requires) {
  if (requirement.kind === "completion") console.log(requirement.quest, requirement.scope)
}
```

Why: quest views report the exact missing condition, and Relay evaluates balances, groups and period completions without overloading one quest-reference shape.

Find it: `rg -n 'requires:|prerequisiteSchema|\.requires|\.missing' --glob '!node_modules/**'`

### Performance evaluators no longer select rewards [#performance-evaluators-no-longer-select-rewards]

A content quest's `performance.evaluate` returns `decision.accept()`, `decision.review({ reason })` or `decision.reject({ reason })` and nothing else. It used to accept `rewards` and ignore them; now passing `rewards` is a type error, and a result that carries them fails the evaluation with "A performance evaluator cannot select rewards". Select rewards in the quest's `evaluate`, which runs before performance.

Old:

```text
evaluate: ({ metrics }) =>
  metrics.likes >= 100 ? decision.accept({ rewards: bonusRewards }) : decision.reject({ reason: "Reach 100 likes." }),
```

New:

```ts
import { decision } from "@domino-sdk/relay/authoring"

export const performance = {
  metrics: ["likes"],
  evaluate: ({ metrics }: { metrics: { likes: number } }) =>
    metrics.likes >= 100 ? decision.accept() : decision.reject({ reason: "Reach 100 likes." }),
}
```

Why: performance gates the rewards content evaluation already selected, so a value in its result never took effect.

Find it: `rg "performance:" -A12 --glob '!node_modules' --glob '*.ts' | rg "rewards"`

### Staff confirmations take no evidence [#staff-confirmations-take-no-evidence]

`manual({ actor: "staff", input })` throws when the quest is defined. Its types never allowed the combination, but JavaScript callers got a quest that members could submit evidence to. Use `manual({ actor: "staff" })` for a staff confirmation, or `manual({ input })` for evidence the member submits.

Old:

```text
trigger: manual({ actor: "staff", input }),
```

New:

```ts
import { manual } from "@domino-sdk/relay/authoring"

export const trigger = manual({ actor: "staff" })
```

Why: staff-captured evidence is not supported, and dropping the actor silently turned a staff confirmation into a quest any member could claim.

Find it: `rg -n 'actor: "staff"' --glob '!node_modules'`

### Evaluators see a frozen clock and seeded randomness [#evaluators-see-a-frozen-clock-and-seeded-randomness]

Hosted evaluation freezes the clock for each evaluation run: `Date.now()`, `new Date()` and `Date()` return `ctx.now` on every retry of the run, including after awaited capability calls, and `performance.now()` returns 0. `Math.random()`, `crypto.getRandomValues()` and `crypto.randomUUID()` repeat the same values within a run, so they are not secret. This applies to every evaluation Domino runs, including releases built with 0.x. See [evaluation time and randomness](/docs/reference/quest-rules#evaluation-time-and-randomness).

Old:

```ts
import { decision, type EvaluationContext } from "@domino-sdk/relay/authoring"

export async function evaluate(ctx: EvaluationContext<{ closesAt: string }>) {
  if (Date.now() > Date.parse(ctx.settings.closesAt)) return decision.reject({ reason: "Closed" })
  return decision.accept()
}
```

New:

```ts
import { decision, type EvaluationContext } from "@domino-sdk/relay/authoring"

export async function evaluate(ctx: EvaluationContext<{ closesAt: string }>) {
  // ctx.eligibleAt is when the activity happened; ctx.now is when this evaluation run started.
  if (ctx.eligibleAt > Date.parse(ctx.settings.closesAt)) return decision.reject({ reason: "Closed" })
  return decision.accept()
}
```

Why: a retried or replayed attempt must make the same decision and `keyed()` completion key.

Find it: `rg -n "Date\.now\(|new Date\(\)|Math\.random\(|randomUUID\(|getRandomValues\(|performance\.now\(" --glob '!node_modules'`

### CLI errors have stable codes and exit statuses [#cli-errors-have-stable-codes-and-exit-statuses]

Every CLI failure has a stable `code`. With `--json`, the error on stderr's last line always has `error`, `code` and `outcome`, and adds `status`, `apiCode`, `details` or `operation` when they apply; parse errors no longer report Commander's codes such as `commander.unknownOption`. Every failure used to exit 1; now:

| Exit | Outcome     | When                                                                                                                                         |
| ---- | ----------- | -------------------------------------------------------------------------------------------------------------------------------------------- |
| 1    | `failed`    | The command failed.                                                                                                                          |
| 2    | `failed`    | Usage error: unknown command or option, or a missing or invalid argument.                                                                    |
| 3    | `uncertain` | A change was sent but not confirmed, such as a lost `deploy` publication response. An interrupted command whose change was sent exits 3 too. |
| 130  | `cancelled` | Ctrl-C (SIGINT) or a declined confirmation, before any change was sent.                                                                      |
| 143  | `cancelled` | SIGTERM, before any change was sent.                                                                                                         |

`deploy --resume` and `stage --request-id` need the full scope as flags (`--api-url`, `--organization`, `--project`, `--environment`), which the error's `operation.resume` supplies; without them they fail with `SCOPE_REQUIRED`. `domino doctor`, `domino check` and `domino migrate` still exit 1 when they find a problem and now also write `DOCTOR_UNHEALTHY`, `CHECK_FAILED` or `MIGRATION_BLOCKED` to stderr. With `--json`, `domino stage` announces its build request as an `{"event": "operation"}` line on stderr instead of the prose line `Build request <id>`. The [CLI automation contract](/docs/reference/automation) lists every code.

Old:

```text
domino deploy --json 2>error.json
if [ $? -eq 1 ]; then jq -r .error error.json; fi
```

New:

```sh
domino deploy --json 2>errors.log
status=$?
if [ "$status" -eq 3 ]; then
  # The change may have landed: finish it instead of deploying again.
  eval "domino $(tail -n 1 errors.log | jq -r '.operation.resume | @sh') --json"
elif [ "$status" -ne 0 ]; then
  echo "domino failed with $(tail -n 1 errors.log | jq -r .code)"
fi
```

Why: automation could not tell a typo from a server refusal, or a failed publication from one that may have succeeded.

Find it: `rg 'commander\.|domino .*(\$\? -eq 1|exitCode === 1|code === 1|status === 1)|\.(error|code) ===? "' --glob '!node_modules'`

### Live deploys return a pending review [#live-deploys-return-a-pending-review]

A deployment record's `status` can be `"pending"`: a person with publish permission approves live publication in Console, so a live deployment is answered before it is published. The deprecated `domino deploy --promote` and `management.deployments.promote(id)` return a pending record, whose ID is a promotion's, and a person can reject it (`"rejected"`). Automation that switches exhaustively on `status` needs these cases or a default branch.

Old:

```text
switch (result.status) {
  case "preview": return review(result)
  case "published": return done(result)
}
```

New:

```ts
import type { DeploymentRecord } from "@domino-sdk/relay/schema"

export function describe(record: DeploymentRecord) {
  switch (record.status) {
    case "published":
      return "Live"
    case "pending":
      return "Waiting for approval in Console"
    default:
      return record.status
  }
}
```

Why: live changes need a person's approval, and approval happens after the deploy call returns.

Find it: `rg 'status.*(published|preview)|deploy --promote|deployments\.promote' --glob '!node_modules'`

### Call the management API with `domino api` [#call-the-management-api-with-domino-api]

`domino GET /path` and the other HTTP methods (`POST`, `PUT`, `DELETE`, `PATCH`) as the first argument are removed and fail with `UNKNOWN_COMMAND` and exit 2. Use `domino api METHOD PATH`, which takes the same body file or `-` for stdin.

Old:

```text
domino POST /trackers tracker.json --json
```

New:

```sh
domino api POST /trackers tracker.json --json
```

Why: the shorthand was an undocumented alias, and 1.0 keeps one spelling per command, so `domino --help` lists the whole public surface.

Find it: `rg 'domino (GET|POST|PUT|DELETE|PATCH) ' --glob '!node_modules'`

### Runtime entry exports only the client [#runtime-entry-exports-only-the-client]

`@domino-sdk/relay` exports only the participant client (`createRelayClient`, `createRelayAction`), its errors (`RelayError`, `isRelayError`, `RelayErrorCode`, `RequestError`, `isRequestError`, `QuestUpdatedError`), the helpers `paginate`, `sessionMember`, `formatUnits` and `parseUnits`, the constants `AttemptStatus`, `QuestOutcomeState` and `PointEntryKind`, and the types the client returns, such as `QuestView`, `Session` and `LeaderboardPage`. Everything else moved:

| Old import from `@domino-sdk/relay` (or `/react` for avatars)                                                                                                                                                | New entry point                                                                         |
| ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------- |
| Any `…Schema`, `identifier`, `caip10`, `xPostId`, `isEvmChainId`, `validateSubmissionValue`, `parseSettings`, record and management types                                                                    | `@domino-sdk/relay/schema`                                                              |
| `defineTracker`, `defineLeaderboard`, `defineReferral`, `defineEngagementReward`, `content`, `streak`, `settings`, `discord`, `pointLedgers`, `x`, `customSource`, `TrackerReader`, `XReader`, `ImageReader` | `@domino-sdk/relay/authoring`                                                           |
| `avatarSrc`, `defaultAvatar`, `defaultAvatarSvg`, `walletAvatarSeed`                                                                                                                                         | `@domino-sdk/relay/avatar`                                                              |
| `buildQuest` from `@domino-sdk/relay/build` without the CLI                                                                                                                                                  | Install `esbuild` next to the SDK                                                       |
| `identityIntegrationSchema`, `identityIntegrationInputSchema`, `participantExchangeSchema`, `questConfiguration`, `parseImageResult`, `parseImageExtraction`                                                 | `@domino-sdk/relay/server`                                                              |
| `identityIntegrationSecretSchema`                                                                                                                                                                            | No longer public                                                                        |
| `isContentParticipantRoute`, `isProfileParticipantRoute`, `isWalletParticipantRoute`, `isWalletSignInRoute`                                                                                                  | `isParticipantRoute` and `isIntegrationParticipantPath` from `@domino-sdk/relay/server` |
| `questActions`, `reconcileQuestDraft`                                                                                                                                                                        | `relay.quests.open(view)`, whose `quest-updated` result carries the reconciled `draft`  |

Old:

```text
import { createRelayClient, avatarSrc, xTweetSchema, defineTracker } from "@domino-sdk/relay"
import { defaultAvatar } from "@domino-sdk/relay/react"
```

New:

```ts
import { createRelayClient } from "@domino-sdk/relay"
import { avatarSrc, defaultAvatar } from "@domino-sdk/relay/avatar"
import { xTweetSchema } from "@domino-sdk/relay/schema"
import { defineTracker } from "@domino-sdk/relay/authoring"
```

Why: an app that only calls Domino loaded every builder and schema. With one kind of code per entry point and `sideEffects: false`, bundlers drop what an app does not import.

Find it: `rg -n "from ['\"]@domino-sdk/relay(/react)?['\"]|avatarSrc|defaultAvatar|walletAvatarSeed" --glob '!node_modules'`

### One paging, session and error convention in the participant client [#one-paging-session-and-error-convention-in-the-participant-client]

The participant client (`createRelayClient`, `createBrowserRelayClient`) follows one convention:

* Every method takes an optional last argument, `{ session?, signal? }`, which replaces positional session arguments. Session-bound calls read the current session when you omit it.
* Every paged read takes `{ cursor? }` and returns `{ items, nextCursor }`, and so do the participant routes and the management methods that share them.
* Every rejection is a `RelayError`, which extends the shared `RequestError`: a `kind` (`http`, `network`, `timeout`, `aborted`, `invalid-response`, `client`), an open string `code` (known ones in `RelayErrorCode`), `status` only when response headers arrived, and `uncertain` with a `recovery` when a write may have taken effect, as [Treat failed writes as uncertain](#treat-failed-writes-as-uncertain-when-relay-cannot-prove-refusal) describes. Test with `isRelayError(error)`: `instanceof` fails across two copies of the package.
* `auth.session()` returns a `ParticipantSession` whose `principal` can be a kind this SDK does not know; read the member with `sessionMember(session)`.
* Methods the participant API refused, duplicate methods and the single-wallet `wallet.*` namespace are gone.

| 0.x                                                                                                                                                           | 1.0                                                                                                             |
| ------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------- |
| `relay.submit(input)`                                                                                                                                         | `relay.me.claim(input)` without `member`, or a quest controller from `relay.quests.open(view)`                  |
| `relay.retry(attemptId)`                                                                                                                                      | `relay.quests.retry(view)`                                                                                      |
| `relay.progress(member)`, `relay.upload(member, file)`                                                                                                        | `relay.me.progress()`, `relay.me.upload(file)`                                                                  |
| `relay.auth.connections()`                                                                                                                                    | `relay.auth.accounts()`                                                                                         |
| `relay.wallet.connection()`                                                                                                                                   | `relay.wallets.list()`                                                                                          |
| `relay.wallet.challenge` and `verify`, or `relay.wallets.challenge(input, session)`                                                                           | `relay.wallets.link(signer)`, or `relay.wallets.challenge(input, { session })` and `verify(proof, { session })` |
| `relay.me.collectionPass(entitlement, session)`                                                                                                               | `relay.me.collectionPass(entitlement, { session })`                                                             |
| `relay.me.linkPass(code)`                                                                                                                                     | Removed: verify passes on your server with `POST /management/v1/passes/verify`                                  |
| `relay.lookupMember`, `snapshot`, `publish`, `publishBatch`, `beginHandover`, `confirmHandover`, `decide`, `journeyTemplate`, `template`                      | Removed: use the management client from `@domino-sdk/relay/server`, `domino deploy` or Console                  |
| `relay.referrals.history(before)`, `relay.engagement.page` and `boundaries`, `relay.content.qualifications` and `performance` with `before`, returning `next` | `{ cursor }`, returning `nextCursor`                                                                            |
| `relay.content.admissions`                                                                                                                                    | `relay.quests.outcomes`                                                                                         |
| `management.referrals.history(member, before)`, `management.content.*`, `management.engagement.page` and `boundaries` with `before` or `after`                | `{ cursor }`, returning `nextCursor` (`engagement.versions` is unchanged)                                       |
| `GET /v1/referrals/history`, `/v1/engagement/*`, `/v1/content/*` with `before` or `after`                                                                     | `cursor`, answering `nextCursor`; a malformed cursor answers 400 `invalid-cursor`                               |
| `error instanceof RelayError`                                                                                                                                 | `isRelayError(error)`                                                                                           |
| `error.status` typed `number`; 400 or 413 from `profiles.uploadAvatar`, 404 from `quests.get`                                                                 | `number \| undefined`; `kind: "client"` with code `invalid-input` or `not-found`                                |
| `ZodError` from invalid input or a changed response                                                                                                           | `RelayError` with code `invalid-input` or `invalid-response`                                                    |
| A plain `Error` from submissions                                                                                                                              | `RelayError` with code `not-signed-in`, `stale-session`, `submission-conflict` or `submission-storage`          |
| A `QuestUpdatedError` replayed from a saved review state, with status 409                                                                                     | `kind: "client"` and no `status`                                                                                |
| A wallet signer's own error from `wallets.link` or `auth.wallet.signIn`                                                                                       | `RelayError` with `kind: "client"`, code `wallet-signer`, and the wallet's error as `cause`                     |
| `session.principal.member`                                                                                                                                    | `sessionMember(session)`                                                                                        |
| `new RelayError(message, status, missing, code)`                                                                                                              | `new RelayError(message, { kind, status, code, missing })`                                                      |

Old:

```text
const history = await relay.referrals.history()
if (history.next) await relay.referrals.history(history.next)
await relay.me.collectionPass("entitlement-id", session.id)
```

New:

```ts
import { createRelayClient, isRelayError, RelayErrorCode } from "@domino-sdk/relay"

const relay = createRelayClient({ baseUrl: "/relay" })
const session = await relay.auth.session()

const history = await relay.referrals.history()
if (history.nextCursor) await relay.referrals.history({ cursor: history.nextCursor })

try {
  await relay.me.collectionPass("entitlement-id", { session: session?.id })
} catch (error) {
  if (isRelayError(error) && error.code === RelayErrorCode.InvalidInput) console.warn(error.message)
}
```

Why: the client paged four ways, bound sessions three ways, kept methods the server refused, and threw plain `Error`s, invented statuses and raw `ZodError`s. One shape per concern lets apps handle every method the same way.

Find it:

```sh
rg -nP "(?<!\.me|\.quests|controller|handle|quest)\.(submit|retry|progress|upload)\(|(?<!ctx)\.wallet\.(connection|challenge|verify)\(|auth\.connections|linkPass|lookupMember|beginHandover|confirmHandover|publishBatch|journeyTemplate|\.decide\(" --glob '!node_modules'
rg -nP "\b(history|page|boundaries|admissions|qualifications|performance)\w*\.next\b(?!\()|\b(before|after): \w+\.next\b|instanceof RelayError|ZodError|principal\.member|collectionPass\(|wallets\.(challenge|verify)\(" --glob '!node_modules'
```

### Custom sources declare their record data [#custom-sources-declare-their-record-data]

`customSource(namespace, options)` requires `data`, a zod object schema for each record's `data` (`z` is exported by `@domino-sdk/relay/authoring`). Without it, `customSource` throws and publication refuses the tracker; a published tracker without a schema takes no records (422 `tracker-schema-required`). Ingestion rejects a batch whose record data does not match with 422 `invalid-record-data`, naming the `record`, `path` and `reason`. A 0.x `authors: "wallet"` option is `identities: ["wallet"]`.

The schema types the data: a tracker-triggered quest's `ctx.tracker` is non-null and `ctx.tracker.record.data` has the schema's type, `trackers.records(tracker)` returns typed records, and `where` paths and `{ setting }` bounds are type-checked. `ctx.tracker` is `null` for triggers that do not supply it, and `ctx.content` is now `ctx.tracker.content.body`. The schema can change under the same key and applies to later records; a trigger skips a stored record that no longer matches with `invalid-record-data`. `where` can be a list of conditions, so code that reads a release's `trigger.tracker.where` handles an array. [Declare record data](/docs/build/trackers#declare-record-data) lists what a schema supports.

Old:

```text
source: customSource("token-balance", { identities: ["wallet"] }),
// In the quest:
const amount = tracker!.record.data.amount
if (typeof amount !== "string") return decision.reject({ reason: "No amount" })
```

New:

```ts
import { automatic, customSource, defineTracker, z } from "@domino-sdk/relay/authoring"

const balances = defineTracker({
  key: "token-balance",
  title: "Token balance",
  source: customSource("token-balance", {
    identities: ["wallet"],
    data: z.object({ amount: z.string(), decimals: z.number().int() }),
  }),
  window: { start: Date.parse("2026-10-01T00:00:00Z") },
})

export const trigger = automatic({ tracker: balances, where: { path: "decimals", equals: 18 } })
```

Why: without a schema, filters named unchecked paths, evaluators narrowed `unknown` values by hand, and a server could store records no quest could read.

Find it: `rg "customSource\(|authors:|tracker!|\.record\.data|trigger\.tracker\.where" --glob '!node_modules'`

### Quest settings come only from settings builders [#quest-settings-come-only-from-settings-builders]

`settings.object(shape)` and `defineQuest({ settings })` accept only fields built with `settings.*` (`integer`, `text`, `boolean`, `choice`, `datetime`, `resource`, `pointLedger`, `quiz`). A plain zod schema used to type-check and fail when the quest was defined; it is now a type error, and `defineQuest` throws `Setting "<key>" is not a quest setting` for JavaScript callers.

Old:

```text
settings: settings.object({ points: z.number().int().default(100) }),
```

New:

```ts
import { settings } from "@domino-sdk/relay/authoring"

export const questSettings = settings.object({
  points: settings.integer({ label: "Points", default: 100, min: 0, max: 100000 }),
})
```

Why: Console edits a setting from the field description its builder records, so a setting without one could never be configured.

Find it: `rg "settings(\.object)?\(\{" -A12 --glob '!node_modules' --glob '*.ts' | rg ": z\."`

### Catch `ManagementError` instead of `CreationError` or a status property [#catch-managementerror-instead-of-creationerror-or-a-status-property]

Every failed management client call rejects with a `ManagementError`, including transport failures that used to escape as `TypeError` or `AbortError`, and invalid input, which used to throw a `ZodError`. `CreationError` is gone: its `actionId` is `ManagementError.operationId`. `error.status` is `undefined` when no response headers arrived, so read `error.kind` first. Every call ends after 30 seconds unless you set another `timeout`, on the client or as `{ signal, timeout }` on a call. See [Management client](/docs/reference/management#management-client).

Old:

```text
import { CreationError } from "@domino-sdk/relay-cli"
} catch (error) {
  if (error instanceof CreationError) retryLater(error.actionId)
  else if ((error as { status?: number }).status === 409) reportConflict(error)
}
```

New:

```ts
import { ManagementError, type ManagementClient } from "@domino-sdk/relay/server"

declare const management: ManagementClient
declare function retryLater(operationId: string | undefined): void

try {
  await management.inventory.createItem({ name: "Shirt" })
} catch (error) {
  if (!(error instanceof ManagementError)) throw error
  if (error.recovery === "replay") retryLater(error.operationId)
  else if (error.kind === "http" && error.status === 409) console.warn(error.code)
}
```

Why: one error type lets automation tell a refusal from a lost answer, and a call without a deadline could hang a Worker or script indefinitely.

Find it: `rg "CreationError|\.status ===|\"status\" in" --glob '!node_modules'`

### Member methods moved under `management.members` [#member-methods-moved-under-managementmembers]

The management client's member methods are one namespace, next to the exact lookup `members.lookup({ provider, subject })`. `management.member(id)` is `management.members.get(id)` and `management.people(ids)` is `management.members.people(ids)`. `management.unlinkMemberWallet(id, input)` and `management.setMemberWalletSignIn(id, input)` are `management.members.identityStates.unlink(id, input)` and `identityStates.setSignIn(id, input)`, which take the identity as `{ kind: "wallet", chain, address }`, the `expectedRevision` from `identityStates.list(id)`, and a `reason`.

Old:

```text
const detail = await management.member(memberId)
await management.unlinkMemberWallet(memberId, { chain, address, reason })
```

New:

```ts
import type { ManagementClient } from "@domino-sdk/relay/server"

declare const management: ManagementClient

const person = await management.members.lookup({ provider: "discord", subject: "80351110224678912" })
const detail = await management.members.get(person.id)
const states = await management.members.identityStates.list(person.id)
const wallet = states.items.find((state) => state.kind === "wallet" && state.canUnlink)

if (wallet?.kind === "wallet") {
  await management.members.identityStates.unlink(person.id, {
    identity: { kind: "wallet", chain: wallet.chain, address: wallet.address },
    expectedRevision: wallet.revision,
    reason: "Reported as compromised",
  })
}
console.log(detail.wallets.length)
```

Why: member reads and actions were spread across the client's top level under four names.

Find it: `rg "management\.(member|people|unlinkMemberWallet|setMemberWalletSignIn)\(|members\.(unlinkWallet|setWalletSignIn)\(" --glob '!node_modules'`

### Import the management client from `@domino-sdk/relay/server` [#import-the-management-client-from-domino-sdkrelayserver]

`@domino-sdk/relay-cli` is only the `domino` executable and its agent skills: it has nothing to import. The management client and everything with it (`createManagementClient`, `hostedManagementClient`, `ManagementError`, the management schemas and types, and the starter list, now `starters`) moved to `@domino-sdk/relay/server`. Server code, including a hosted app's Worker, needs only `@domino-sdk/relay`; keep `@domino-sdk/relay-cli` as a development dependency. `serverBoundary()` from `@domino-sdk/relay/vite` fails a browser build that imports `@domino-sdk/relay/server`.

Old:

```text
import { hostedManagementClient } from "@domino-sdk/relay-cli"
```

New:

```ts
import { hostedManagementClient } from "@domino-sdk/relay/server"

export const management = (env: Record<string, string | undefined>) => hostedManagementClient(env)
```

Why: server integrations installed the CLI, with its parser and TypeScript compiler, to reach a small fetch client.

Find it: `rg "from ['\"]@domino-sdk/relay-cli['\"]|import\(['\"]@domino-sdk/relay-cli['\"]\)" --glob '!node_modules'`

### Set the timezone once, on the project calendar [#set-the-timezone-once-on-the-project-calendar]

`streak()` and calendar leaderboard windows no longer take a `timeZone`. Each project environment has one [project calendar](/docs/concepts/calendar), UTC until you choose a timezone, and streak days, budgets and calendar leaderboards count its days, weeks and months. `streak({ days, timeZone })` throws, and a definition that still carries a `timeZone` fails validation. Fixed offsets such as `+02:00`, `Etc/GMT-2` and `EST` are rejected, and other spellings of a zone resolve to its canonical name.

Without a code change, a `"day"` or `"week"` budget now resets at midnight in the project timezone instead of UTC, and published leaderboards with their own `timeZone` follow the project calendar. A project whose streak quests named a timezone before 1.0 keeps it as its project calendar; otherwise the calendar is UTC, so set the project timezone to keep a board on its old zone. Budgets also accept `period: "month"`.

Old:

```text
completion: streak({ days: 7, timeZone: "Europe/Budapest" }),
window: { kind: "calendar", period: "week", timeZone: "Europe/Budapest" },
```

New: set `"calendar": { "timeZone": "Europe/Budapest" }` in `relay.json`, or in Console **Settings → General → Project calendar**, and drop the zone from code. It takes effect at the next midnight in the current timezone.

```ts
import { streak } from "@domino-sdk/relay/authoring"

export const completion = streak({ days: 7 })
export const weekWindow = { kind: "calendar", period: "week" } as const
```

Why: a weekly budget and a weekly leaderboard in one project could cover different weeks. One calendar makes every day, week and month in a project mean the same thing.

Find it: `rg -n "timeZone|period: \"(day|week)\"" --glob '!node_modules' --glob '*.ts' --glob '*.tsx'`

### Engagement budgets are a list of periods [#engagement-budgets-are-a-list-of-periods]

`defineEngagementReward` takes `budgets`, a list of `{ scope, period, limit }`, instead of `budget: { projectDaily, memberDaily, projectLifetime }`. `scope` is `"project"` or `"member"`, and `period` is `"day"`, `"week"`, `"month"` or `"lifetime"` of the project calendar at settlement time. A definition with `budget` fails validation; published rules keep their limits. Engagement accounts settle once per project calendar day instead of once per UTC day.

Old:

```text
budget: { projectDaily: 10000, memberDaily: 500, projectLifetime: 100000 },
```

New:

```ts
export const budgets = [
  { scope: "project", period: "day", limit: 10000 },
  { scope: "member", period: "day", limit: 500 },
  { scope: "project", period: "lifetime", limit: 100000 },
] as const
```

Why: engagement budgets use the period vocabulary of quest budgets and leaderboards, so a rule can cap weekly or monthly payouts.

Find it: `rg -n "projectDaily|memberDaily|projectLifetime" --glob '!node_modules'`

### React hooks share one cache and return one state shape [#react-hooks-share-one-cache-and-return-one-state-shape]

Every read hook in `@domino-sdk/relay/react` returns `{ status, data, error, refresh }`. `status` is `loading`, `ready` or `error`, and `signed-out` for hooks that need a member; `error` is a `RelayError`, which while `ready` says why the latest refresh failed. `<RelayProvider>` keeps one query cache: components that read the same thing share a request, changes through the hooks refresh what they affect, polling pauses while the tab is hidden, and the cache resets when the participant, project, environment or client changes. `questStatus` returns a code instead of English.

| Old                                                                                                                                                | New                                                                                                                                                                                                                                                     |
| -------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `const { experience, member, deliveries, refresh } = useExperience()`                                                                              | `const experience = useExperience()`, with `deliveries` and `refresh` on it; `useSession().member` for the member                                                                                                                                       |
| `experience.quests`, `experience.message`, `experience.stale`                                                                                      | `experience.data.quests`, `experience.error.message`, `experience.error` while `ready`                                                                                                                                                                  |
| `Experience`, `Extract<Experience, { status: "ready" }>`                                                                                           | `ExperienceData` (`{ quests, collections }`); `questGroups(experience.data, order)`                                                                                                                                                                     |
| `useMe()`, `useSignInMethods()` returning a value or null                                                                                          | `.data` when `status` is `ready`                                                                                                                                                                                                                        |
| `useAccounts().accounts`, `useWallets().wallets`, `useMyProfile().profile`, `useReferralSummary().summary`                                         | `.data`; a failed account, wallet or profile action lands in `actionError`, and account and wallet actions resolve `true` or `false`                                                                                                                    |
| `useLeaderboard(id).page`, `useProfile(id).profile`, `.value` of placement and referral hooks                                                      | `.data`                                                                                                                                                                                                                                                 |
| `status: "missing"` from the profile hooks                                                                                                         | `status: "error"` with `error.status === 404`                                                                                                                                                                                                           |
| `useProfileCompletions(id)` and `usePointHistory(ledger)`: `items`, `more`, `loading`, an error while ready                                        | `data.items`, `data.nextCursor !== null`, `loadingMore`, `loadMoreError`; history errors use codes `point-ledger-not-found` or `not-joined`                                                                                                             |
| `usePointLedgers().items`, `useTieredRewards().items`, `useBalances().items`                                                                       | `.data` (`.data.items` for balances)                                                                                                                                                                                                                    |
| `PointLedgersState`, `LeaderboardState`, `ProfileState` and the other `*State` types                                                               | `Query<T>`, `MemberQuery<T>` (`BalancesState` is `MemberQuery<PointBalances>`), `PagedQuery<T>`, `MemberPagedQuery<T>`                                                                                                                                  |
| `PROFILE_CHANGED` window event                                                                                                                     | Removed; after a change made with `useRelay()`, call `useInvalidate()`                                                                                                                                                                                  |
| `questStatus(view).label`, `.detail`                                                                                                               | `questStatusText(view)`; `questStatus(view)` returns `{ tone, code, reason? }`                                                                                                                                                                          |
| `roleDeliveryLabel(deliveries, quest)`                                                                                                             | `roleDeliveryLabel(roleDeliveryStatus(deliveries, quest))`                                                                                                                                                                                              |
| An exhaustive `switch` over `questStatus(view).code` or `roleDeliveryStatus(…)`                                                                    | Add a `default`: both are open strings                                                                                                                                                                                                                  |
| `completionCountLabel(view)`                                                                                                                       | `completionCountText(view)`                                                                                                                                                                                                                             |
| Changing `useExperience().deliveries` in place                                                                                                     | Copy it first: it is a `readonly RoleDelivery[]`                                                                                                                                                                                                        |
| An effect that calls `useReferralSummary().refresh()` on a timer                                                                                   | Remove it: the summary refreshes every 60 seconds while visible                                                                                                                                                                                         |
| `useQuest(view).error`, `useSignIn().error`, `useSignInOptions().error` and `.methodsError`, `SignInMethods.error` as strings; `invitation.reason` | `RelayError \| null`, and `invitation.error`: render `error.message`                                                                                                                                                                                    |
| `completionMark`, `hasPending`, `sanitizeDeliveries`, `watchRoleDeliveries`, `PENDING_POLL_MS`, `RETRY_DELAYS_MS`, `runsSilently`, `visibleQuests` | Not exported. `useExperience()` leaves hidden quests out; `runsSilently(view)` was `view.trigger.kind === "automatic" && (view.completion.kind === "keyed" \|\| Boolean(view.trigger.tracker))`; check `delivery.status === "pending"` for `hasPending` |
| A `useState` and `useEffect` loop over `useRelay()`                                                                                                | `useRelayQuery(key, read)` or `useMemberQuery(key, read)`; `useRelayPages` or `useMemberPages` for paged reads                                                                                                                                          |

Old:

```text
const { experience, member } = useExperience()
if (experience.status === "error") return <p>{experience.message}</p>
```

New:

```tsx
import { questStatusText, useExperience, useSession } from "@domino-sdk/relay/react"

export function Summary() {
  const experience = useExperience()
  const { member } = useSession()
  if (experience.status === "error") return <p>{experience.error.message}</p>
  if (experience.status !== "ready") return null
  const quest = experience.data.quests[0]
  return <p>{member} {quest ? questStatusText(quest).label : null}</p>
}
```

Why: each hook ran its own fetch loop, so two components fetched twice, a claim left other reads stale until their next poll, and a failing experience poll blanked unrelated hooks. Status codes let apps translate quest states.

Find it: `rg -n "useExperience\(\)|useMe\(\)|useSignInMethods\(\)|\.reason\b|completionMark|hasPending|runsSilently|visibleQuests|watchRoleDeliveries|\.(accounts|wallets|summary|page|profile|items|value|stale|more)\b|status === \"missing\"|PROFILE_CHANGED|questStatus\(|roleDeliveryLabel\(|completionCountLabel|State\b" --glob '!node_modules' --glob '*.{ts,tsx}'`

### Observation triggers become tracker triggers [#observation-triggers-become-tracker-triggers]

`automatic({ source, type })`, `ctx.observation`, `management.observations.submit`, `POST /management/v1/observations`, the `Observation` type, `observationSchema`, `observationInputSchema`, `observationReceiptSchema`, `observationTriggerSchema`, `discordReactionSchema`, and the `observation` field of an automatic trigger (`QuestView.trigger.observation`) and of the `automatic` availability are removed. A server that reported observations sends records to a [custom tracker](/docs/build/trackers#feed-another-source-including-a-blockchain-indexer) with `management.trackers.ingest`, and the quest triggers with `automatic({ tracker })`. Quests published with an observation trigger admit nothing; republish each on a tracker, under the same key if you like.

| Observation           | Tracker record                                                                                                                                                                  |
| --------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `id`                  | `id`                                                                                                                                                                            |
| `occurredAt`          | `occurredAt` and `observedAt`                                                                                                                                                   |
| `data`                | `data`, matching the source's `data` schema                                                                                                                                     |
| `type`                | A field in `data` that the quest's `where` selects, or a separate tracker                                                                                                       |
| `source: "backend:…"` | The custom source's namespace                                                                                                                                                   |
| `member`              | The record's `author` or a named account. Prefer the `wallet`, `x` or `discord` account the activity came from; use a `member` account when your server vouches for the member. |

Ingesting a record never joins the member: a trigger skips a member who has not joined with `not-joined`. Every selected record gets an outcome, an admission or a skip with a stable code; see [Explain trigger outcomes](/docs/build/trackers#explain-trigger-outcomes).

Discord activity comes from two managed tracker sources, `discord.messages()` and `discord.reactions()`, with typed record data. Relay stores a Discord record only when one of its accounts is linked to a member; see [Reward Discord messages and reactions](/docs/build/trackers#reward-discord-messages-and-reactions).

| Old                                                        | New                                                                                                                                |
| ---------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------- |
| `automatic({ source: "discord", type: "message.create" })` | `automatic({ tracker })` on a `discord.messages()` tracker                                                                         |
| `type: "reaction.add"`                                     | `automatic({ tracker })` on a `discord.reactions()` tracker                                                                        |
| `type: "reaction.received"`                                | The reactions tracker with `beneficiary: "messageAuthor"`; reactions to one's own message are skipped with `beneficiary-is-author` |
| `discordReactionSchema.parse(ctx.observation.data)`        | `ctx.tracker.record.data`                                                                                                          |
| A reaction's `userId`, `messageAuthorId`, `actorRoles`     | `ctx.tracker.record.author.id`, `ctx.tracker.record.accounts.messageAuthor.id`, `data.reactorRoles`                                |
| A message's `userId`, `content`                            | `ctx.tracker.record.author.id`, and `data.text` on a `discord.messages({ text: true })` tracker                                    |
| `quest.trigger.observation?.source === "discord"`          | `quest.trigger.tracker?.source` is `"discord-messages"` or `"discord-reactions"`                                                   |

`questStatus` from `@domino-sdk/relay/react` gives a tracker-triggered quest the code `automatic` ("Automatic") unless a counter, streak or cooldown code applies first.

Old:

```text
trigger: automatic({ source: "backend:orders", type: "paid" }),
async evaluate({ observation }) { return Number(observation!.data.total) >= 1000 ? decision.accept() : decision.reject({ reason: "…" }) },

await management.observations.submit({ id: "order-1842", source: "backend:orders", type: "paid", member: "member_existing", occurredAt: paidAt, data: { total: 4200 } })
```

New:

```ts
import { automatic, customSource, defineTracker, z } from "@domino-sdk/relay/authoring"
import type { ManagementClient } from "@domino-sdk/relay/server"

declare const management: ManagementClient

export const orders = defineTracker({
  key: "orders",
  title: "Orders",
  source: customSource("orders", {
    identities: ["member"],
    data: z.object({ status: z.enum(["paid", "refunded"]), total: z.number().int() }),
  }),
  window: { start: Date.parse("2026-10-01T00:00:00Z") },
})

// The old observation type is a data field the filter selects.
export const trigger = automatic({ tracker: orders, where: { path: "status", equals: "paid" } })

// Your server sends the record with a key that has `ingest` permission.
const paidAt = Date.now()
await management.trackers.ingest("orders", {
  actionId: "orders-order-1842-paid",
  records: [{
    id: "order-1842", author: { id: "member_existing" }, occurredAt: paidAt, observedAt: paidAt,
    data: { status: "paid", total: 4200 },
  }],
})
```

Why: observations were a second automatic pipeline that matched only `source` and `type`, dropped everything else silently, and joined the member the caller named. Tracker triggers already had filters, beneficiary roles, attribution through verified links and a recorded outcome for every decision.

Find it: `rg 'automatic\(\{\s*source|\.observation\b|\bobservation\b[?!]?\.|observations\.submit|/observations|\bObservation\b|observation(Input|Receipt|Trigger)?Schema|discordReactionSchema|reaction\.(add|received)|message\.create' --glob '!node_modules'`

### The `observe` permission is replaced by `enroll` [#the-observe-permission-is-replaced-by-enroll]

The `observe` permission is removed. Sending tracker records (`management.trackers.ingest`) needs `ingest`, and enrolling a member from a server (`management.quests.enroll`) needs the new `enroll`. Editors hold both; a hosted-app key holds `read`, `points` and `ingest` and cannot enroll; a personal API key issued with `observe` keeps only its other permissions. Reissue such a key in Console **Settings → API keys** with **Ingest**, **Enroll** or both, and replace it wherever it is stored.

Old:

```text
// A personal API key issued with only "observe".
await management.quests.enroll({ actionId, quest: "launch-post", member: "member_existing" })
```

New:

```ts
import type { ManagementClient } from "@domino-sdk/relay/server"

// A client whose key was issued with Enroll.
declare const management: ManagementClient

await management.quests.enroll({ actionId: "enroll-launch-post-member-existing", quest: "launch-post", member: "member_existing" })
```

Why: enrolling vouches for a member's consent, which is more than reporting activity, so keys that only send records cannot enroll.

Find it: `rg '"observe"|\bobserve\b' --glob '!node_modules'`

### Readers return `null` when an account is not linked or a subject is missing [#readers-return-null-when-an-account-is-not-linked-or-a-subject-is-missing]

Every evaluator reader answers a missing subject with `null` instead of throwing, an empty list or a zero balance. `ctx.x.profile()`, `ctx.x.follows()` and `ctx.x.reposted()` return `null` without a linked X account instead of throwing. `ctx.x.post()` returns `null` for a missing post and needs no linked account. `ctx.discord.member()` returns `null` without a linked Discord account. `ctx.wallet.list()` returns `null` instead of `[]`, and `ctx.wallet.balance()` returns `null` instead of `{ total: "0", wallets: [] }` when the member has no wallet of that chain's family. `ctx.trackers.content()` returns `null` for a missing record or observation. See [evaluator readers](/docs/reference/verification#evaluator-readers-null-errors-and-pinning).

Old:

```text
const held = await ctx.wallet.balance({ chain: "eip155:8453" })
if (held.wallets.length === 0) return decision.reject({ reason: "Link a wallet on this chain first." })
```

New:

```ts
import { decision, type EvaluationContext } from "@domino-sdk/relay/authoring"

// In a quest that declares `integrations: [x]`.
export async function evaluate(ctx: EvaluationContext<{}>) {
  const held = await ctx.wallet.balance({ chain: "eip155:8453" })
  if (!held) return decision.reject({ reason: "Link a wallet on this chain first." })
  // null: no linked X account. false: linked, and not following.
  const follows = await ctx.x.follows({ userId: "2244994945" })
  if (follows === null) return decision.reject({ reason: "Connect X first." })
  return follows && BigInt(held.total) > 0n ? decision.accept() : decision.reject({ reason: "Follow us and hold the token." })
}
```

Why: each reader reported "missing" its own way, and a zero total looked like an answer. With one rule the types say which reads can be missing.

Find it: `rg -n "ctx\.(x|wallet|discord|trackers)\.|\.wallets\.length|total === \"0\"" --glob '!node_modules'`

### Capability failures throw `CapabilityError` [#capability-failures-throw-capabilityerror]

A capability that cannot answer throws `CapabilityError` from `@domino-sdk/relay/authoring`, with a `code` (`"not-linked"`, `"provider-unavailable"` or `"author-error"`) and `retryable`, instead of a plain error. Invalid input, such as an unknown chain, an undeclared Discord integration or an `address` the member never linked, is `author-error`; an image check of `{ x: "profile" }` without a linked X account is `not-linked`. A retryable failure that escapes the evaluation is released, along with every fact captured after it, and the retry asks again. One that quest code catches and moves past is final for the attempt and replays, unless you rethrow it or wrap it with `{ cause: error }`; `throw new Error(error.message)` loses the link. Any other failure is answered again on every run, and an unknown `code` from a newer Relay is never retryable.

Old:

```text
} catch (error) {
  if (error instanceof Error && error.message.includes("Connect your X account")) return decision.reject({ reason: "Connect X first." })
  throw error
}
```

New:

```ts
import { CapabilityError, decision, type EvaluationContext } from "@domino-sdk/relay/authoring"

// In a quest that declares `integrations: [x]`.
export async function evaluate(ctx: EvaluationContext<{}>) {
  try {
    const match = await ctx.images.compare({ x: "profile" }, { asset: "frame" })
    return match.phashDistance <= 8 ? decision.accept() : decision.reject({ reason: "Use the campaign picture." })
  } catch (error) {
    if (error instanceof CapabilityError && error.code === "not-linked") return decision.reject({ reason: "Connect X first." })
    throw error
  }
}
```

Why: quest code could not tell "link an account" from "the provider is down" from "this quest has a bug" without matching messages, which change.

Find it: `rg -n "catch \(|message\.(includes|match|startsWith)|Connect your X account" --glob '!node_modules'`

### Tracker reads are point in time and pinned per attempt [#tracker-reads-are-point-in-time-and-pinned-per-attempt]

`ctx.trackers.records()` and `ctx.trackers.summary()` take `asOf`, defaulting to `ctx.eligibleAt`: only records observed at or before it count, each as its latest such observation. Each evaluation run pins a watermark at its first tracker read, so every tracker read of the run and its retries sees what Relay had stored then. A tracker-triggered quest counts records up to its triggering record's `occurredAt`, and a manual quest up to its submission. Pass `asOf: ctx.now` to count everything stored by the watermark.

Old:

```text
// Counted every record collected so far, whenever the evaluation ran.
const posts = await ctx.trackers.records("launch-posts", { member: ctx.member.id })
```

New:

```ts
import { decision, type EvaluationContext, type Tracker } from "@domino-sdk/relay/authoring"

declare const launchPosts: Tracker

export async function evaluate(ctx: EvaluationContext<{}>) {
  const posts = await ctx.trackers.records(launchPosts, { member: ctx.member.id, asOf: ctx.now })
  return posts.total >= 3 ? decision.accept() : decision.reject({ reason: "Post 3 times." })
}
```

Why: a decision must not depend on when an evaluation happened to run, so every read and retry of an attempt gets the same answer.

Find it: `rg -n "trackers\.(records|summary)\(" --glob '!node_modules'`

### Linked X and Discord accounts stay pinned for the attempt [#linked-x-and-discord-accounts-stay-pinned-for-the-attempt]

`ctx.x` and `ctx.discord` pin the member's account at the evaluation run's first read of each and never recheck it, as `ctx.wallet` already did; "not linked" is pinned too. Reads no longer fail with "X account changed. Start a new submission." or "Account or integration changed. Start a new verification.". An account linked after the first read is used by a new submission or a scheduled deferred run. Image checks of `{ x: "profile" }` and `ctx.trackers.content()` attribution use the same pinned X account.

Old:

```text
await ctx.x.profile() // Error: X account changed. Start a new submission.
```

New:

```ts
import { decision, type EvaluationContext } from "@domino-sdk/relay/authoring"

// In a quest that declares `integrations: [x]`.
export async function evaluate(ctx: EvaluationContext<{ phrase: string }>) {
  // The account pinned at this attempt's first X read; null if none was linked then.
  const profile = await ctx.x.profile()
  if (!profile) return decision.reject({ reason: "Connect X, then start a new submission." })
  return (profile.bio ?? "").includes(ctx.settings.phrase) ? decision.accept() : decision.reject({ reason: "Add the phrase to your bio." })
}
```

Why: each reader pinned differently, so an attempt could fail or not depending on which reader ran.

Find it: `rg -n "X account changed|Account or integration changed|start a new (submission|verification)" --glob '!node_modules'`

### Entitlements can be `void`, and reads tolerate statuses they do not know [#entitlements-can-be-void-and-reads-tolerate-statuses-they-do-not-know]

An `Entitlement`'s `status` can be `"void"`, with `voidedAt` and no handover fields, after an operator voids a tiered reward granted by mistake. It cannot be collected; a later grant of the same reward makes it `available` again, while a voided referral reward stays void. A 0.x SDK rejects the whole progress read (`relay.me.progress()`, `useExperience()`) and the collection pass preview once a member has a voided entitlement, so upgrade first.

Reads parse entitlements with a tolerant `entitlementSchema`: objects keep fields a newer server adds, `inventory.kind` and `fulfillment.kind` are plain strings, and an unknown status reads as `"unsupported"` with the server's value in `reportedStatus`; show it but offer no action. The strict stored shape is `entitlementStateSchema`, and the management client's `entitlementRecordSchema` is gone.

Old:

```text
const startedAt = entitlement.status !== "available" ? entitlement.startedAt : null
```

New:

```ts
import type { Entitlement } from "@domino-sdk/relay/schema"

export const startedAt = (entitlement: Entitlement) =>
  entitlement.status === "handing-over" || entitlement.status === "collected" ? entitlement.startedAt : null
```

Why: operators need a way out of a reward granted by mistake, and tolerant reads let a later status reach old apps without breaking them.

Find it: `rg 'status !== "available"|status != "available"|case "collected"|entitlementRecordSchema' --glob '!node_modules'`

### Counter progress [#counter-progress]

Counter completion policies add `kind: "progress"` and the attempt status `progressed`. A qualifying action below the goal has no completion and pays no reward; read `QuestView.counter` for the count and achievement. Don't infer an awarded completion from a successful submission. Move app-side counting into a quest with a new key, since a counter cannot replace a published quest's completion policy. See [counter rules](/docs/concepts/quests#distinct-action-counters).

Old:

```text
if (acceptedActions.length >= 3) await awardReward()
```

New:

```ts
import { calendar, count, progress } from "@domino-sdk/relay/authoring"

export const completion = progress({
  distinct: ({ calendar }) => calendar.day().key,
  goal: count(3),
  window: calendar("week"),
})
```

Why: counting and rewarding in separate app requests could double-award on retries; the server deduplicates evidence and commits the achievement with its rewards.

Find it: `rg -n -e 'acceptedActions' -e 'completion.kind' -e 'attempt.status' -e 'case "accepted"' --glob '*.{ts,tsx,js,mjs}'`

### Handle deferred evaluator decisions and progress [#handle-deferred-evaluator-decisions-and-progress]

Quest `evaluate` can return `decision.defer(...)` and `decision.progress(...)`. A waiting attempt has status `deferred`, an open `code` and `nextEvaluationAt`, and `QuestView.progress` holds its committed progress; waiting grants nothing. Attempt statuses are open strings: use the `AttemptStatus` constants and a fallback, also for `QuestView.attempt`. A new submission while deferred answers 409 `attempt-pending`. An explicit `completionKey` on `decision.accept` or `decision.review` takes precedence over a `keyed()` callback. See [the bounds and expiry codes](/docs/reference/quest-rules#defer-evaluation-or-report-progress).

Old:

```text
if (!ready) throw new Error("Not ready yet")
```

New:

```ts
import { decision } from "@domino-sdk/relay/authoring"

export const check = (ready: boolean) =>
  ready ? decision.accept() : decision.defer({ reason: "Not ready yet", retryAfterMs: 60_000 })
```

Why: a throw retries the same evaluation run with its captured clock and reads. A defer starts a fresh run later, with fresh captures, while the trigger evidence and `ctx.eligibleAt` stay pinned.

Find it: `rg -n 'throw new Error|switch.*(kind|status)|completionKey' --glob '*.{ts,tsx,js,mjs}'`

### Enroll in quests with `relay.quests.enroll` [#enroll-in-quests-with-relayquestsenroll]

`relay.content.enroll`, `management.content.enroll` and their `content/enrollments` routes are removed. Members enroll with `relay.quests.enroll({ actionId, quest })`, which takes no `member`. A server with `enroll` permission calls `management.quests.enroll({ actionId, quest, member })`. A quest whose trigger does not require enrollment answers 409 `enrollment-not-required`. Earlier enrollments keep their first time.

Old:

```text
await relay.content.enroll({ actionId, quest: "launch-post", member: memberId })
```

New:

```ts
import { createRelayClient } from "@domino-sdk/relay"

const relay = createRelayClient({ baseUrl: "/relay" })
await relay.quests.enroll({ actionId: crypto.randomUUID(), quest: "launch-post" })
```

Why: any tracker trigger can require enrollment, so enrollment is a quest record, and a member can only enroll themselves.

Find it: `rg -n "content\.enroll|content/enrollments|contentEnrollment" --glob '!node_modules'`

### Tracker triggers hold activity from before an account was linked [#tracker-triggers-hold-activity-from-before-an-account-was-linked]

Tracker triggers apply the admission rules every automatic quest shares ([Historical activity](/docs/concepts/trackers#historical-activity)):

* **Historical activity waits.** A record from before the member linked the account it pays, or with no known link time, is skipped with `historical` instead of admitted. Set `historical: "automatic"` to admit it as before, or `"claim"` to let the member claim it with `relay.quests.claim()`. `member` accounts are never historical.
* **Skips are decided again** when the event that clears them happens: the member links the account, a conflicting link is removed, or the member joins or enrolls.
* **Every selected observation has an outcome**, including `already-completed` for a completed `once()` quest or a streak whose milestone paid, which `ingest` counts in `admissions.skipped`.
* **Prerequisites are judged at the record's time**, so a record from before the member completed a prerequisite is skipped with `prerequisites-incomplete`.
* **Skip codes are an open set**: `TrackerSkipCode` is a known code or any string; exhaustive checks use `KnownTrackerSkipCode`.
* **`management.trackers.retryOutcome` rejects with the skip code in `code`**, and a completed quest with `already-completed` instead of "Already completed".

Old:

```text
trigger: automatic({ tracker: tokenBalance, on: "data-changed" }),
```

New:

```ts
import { automatic, type Tracker } from "@domino-sdk/relay/authoring"

declare const tokenBalance: Tracker

// Admit activity from before the member linked the wallet, as 0.x did.
export const trigger = automatic({ tracker: tokenBalance, on: "data-changed", historical: "automatic" })
```

Why: an account's earlier activity was paid to whoever linked it later. Holding it fails closed, and each quest says who may release it.

Find it: `rg -n "automatic\(\{|retryOutcome|Already completed|TrackerSkipCode|\.skip\b" --glob '!node_modules'`

### Identity kinds are open, and evaluators read `ctx.member.identities()` [#identity-kinds-are-open-and-evaluators-read-ctxmemberidentities]

`IdentityKind` adds your app's own kinds, `` `app:${string}` `` such as `app:game`, beside `wallet`, `x`, `discord` and `member`; see [app login](/docs/build/app-login#link-your-apps-user-ids). Responses and evaluator contexts read kinds as an open set:

* `identityKindSchema` has no `.options` or `.enum`, and a `Record<IdentityKind, …>` or exhaustive `switch` misses app kinds.
* Kinds in responses and contexts are `OpenIdentityKind`, `IdentityKind | (string & {})`: a record's `author.kind` and account `kind`, each `ctx.member.identities()` entry, and a published tracker's `source.identities`.
* `ctx.tracker.attribution.account` can be `{ provider: "app:<name>", subject, revision, linkedAt }`, or `{ provider: string }` for a later kind, and the trigger context schemas ignore unknown fields.
* Published tracker definitions read as `trackerDefinitionResponseSchema` (`TrackerDefinitionResponse`); keep `trackerDefinitionSchema` for definitions you publish. `trackerRecordSchema` is a separate tolerant response schema; validate what you send with `trackerRecordInputSchema`.
* `ctx.member` is an `EvaluationMember`, `{ id, identities() }`, which can gain methods in a minor release. Build test contexts with `createTestContext` or `runQuest` from `@domino-sdk/relay/testing`, or the member alone with `fakeEvaluationMember`.

Old:

```text
const labels: Record<IdentityKind, string> = { wallet: "Wallet", x: "X", discord: "Discord", member: "Member" }
const readDefinition = (body: unknown) => trackerDefinitionSchema.parse(body)
const member = { id: "member_1" }
```

New:

```ts
import { trackerDefinitionResponseSchema, type KnownIdentityKind, type OpenIdentityKind } from "@domino-sdk/relay/schema"
import { fakeEvaluationMember } from "@domino-sdk/relay/testing"

const labels: Record<KnownIdentityKind, string> = { wallet: "Wallet", x: "X", discord: "Discord", member: "Member" }
export const label = (kind: OpenIdentityKind) => (Object.hasOwn(labels, kind) ? labels[kind as KnownIdentityKind] : kind)
export const readDefinition = (body: unknown) => trackerDefinitionResponseSchema.parse(body)
export const member = fakeEvaluationMember({ id: "member_1" })
```

Why: apps that know users by their own IDs had to resolve member IDs themselves. Open kinds let a later version add kinds without breaking an SDK you shipped, so keep a default branch wherever you branch on a kind.

Find it: `rg -n "IdentityKind|identityKindSchema|TrackerDefinition|trackerDefinitionSchema|trackerRecordSchema|attribution\.account|account\.provider|member: \{ id" --glob '!node_modules'`

### Reward delivery actions are open strings [#reward-delivery-actions-are-open-strings]

A reward delivery's `action` is an open string: `"grant-role"` for a Discord role reward, `"post-message"` for a quest announcement, or an action a newer Relay adds (the known values are `RewardDeliveryAction`). Show a delivery with another action by its status, and never treat it as a grant. When an operator revokes a completion, its grants get `revokedAt`; a pending grant becomes failed. Use `revokedAt` rather than `lastError` text to tell role reconciliation from a retry. A non-null `dispatchedAt` means the role may have been granted even if the delivery failed. A `delivered` grant of a revoked completion is history; its `reconciliation` says whether the member holds the role now.

Old:

```text
const granted = deliveries.filter((d) => d.status === "delivered")
```

New:

```ts
import { RewardDeliveryAction } from "@domino-sdk/relay/schema"

export const describe = (d: { action: RewardDeliveryAction; status: string }) =>
  d.action === RewardDeliveryAction.GrantRole ? `Role ${d.status}` : `Delivery ${d.status}`
```

Why: a fixed `"grant-role"` literal would make every new delivery action a breaking change.

Find it: `rg 'rewardDeliveries|"grant-role"|deliveredAt' --glob '!node_modules'`

### Bounded requests and avatar recovery [#bounded-requests-and-avatar-recovery]

Participant requests default to a 15-second deadline, and management and CLI requests to 30 seconds, covering every attempt, the waits between them and the response body. Calls that are safe to repeat retry transient failures, up to three attempts. Wallet signer waits stay outside the deadline, but the caller's signal cancels them. Choose recovery from `error.recovery`, not from the presence of `operationId`: avatar uploads have an upload ID but no server receipt, so they report `"reconcile"`.

Old:

```text
if (isRelayError(error) && error.uncertain && error.operationId) await client.profiles.uploadAvatar(photo, { uploadId: error.operationId })
```

New:

```ts
import { createRelayClient, isRelayError } from "@domino-sdk/relay"

const client = createRelayClient({ baseUrl: "/relay" })
declare const error: unknown

// Read the current profile before deciding whether to upload again.
if (isRelayError(error) && error.uncertain && error.recovery === "reconcile") await client.profiles.mine()
```

Why: a call without a deadline could hang indefinitely, and replaying an older avatar upload could replace a newer avatar.

Find it: `rg -n 'operationId|uploadAvatar|recovery|wallets\.link' --glob '!node_modules'`

### Generic management writes require reconciliation [#generic-management-writes-require-reconciliation]

Generic management `request` and `raw` writes report `recovery: "reconcile"` when their outcome is uncertain, even with a caller-supplied `actionId`, which used to make them recommend `"replay"`. Typed methods whose routes keep receipts still report `"replay"`. An uncertain `domino api` write reports `details.recovery: "reconcile"`.

Old:

```text
if (isManagementError(error) && error.uncertain && error.operationId) await management.request("/custom", schema, { actionId: error.operationId })
```

New:

```ts
import { isManagementError } from "@domino-sdk/relay/server"

declare const error: unknown
declare function repeatTypedCall(operationId: string | undefined): Promise<void>
declare function readResourceFirst(): Promise<void>

if (isManagementError(error) && error.uncertain) {
  if (error.recovery === "replay") await repeatTypedCall(error.operationId)
  else await readResourceFirst() // A generic write: read before writing again.
}
```

Why: an action ID does not prove that an arbitrary endpoint keeps a receipt, and repeating a write without one can apply it twice.

Find it: `rg -n 'operationId|management\.(request|raw)|domino api (POST|PUT|PATCH|DELETE)' --glob '!node_modules'`

### Treat failed writes as uncertain when Relay cannot prove refusal [#treat-failed-writes-as-uncertain-when-relay-cannot-prove-refusal]

Relay returns 503 only when the request made no committed write and no external effect. Unexpected failures are 500, and lost or unusable upstream answers are 502, or 504 on timeout. Evidence uploads, avatar uploads, email delivery, key cleanup and hosted activation can return 502 or 504 where 0.x servers returned 503. On 429 or 503, respect `Retry-After`. For direct HTTP email sign-in, an unconfirmed delivery answers 502 or 504 with a `challenge` and the binding cookie: if the code arrives, verify it with that challenge in the same browser. The SDK does not resend an unconfirmed start.

Old:

```text
if ([500, 502, 503, 504].includes(error.status)) await submit({ actionId: crypto.randomUUID() })
```

New:

```ts
import { isRelayError } from "@domino-sdk/relay"

declare const error: unknown
declare function submit(input: { actionId: string }): Promise<void>
declare function reconcile(): Promise<void>

if (isRelayError(error) && error.uncertain) {
  if (error.recovery === "replay" && error.operationId) await submit({ actionId: error.operationId })
  else await reconcile()
}
```

Why: a lost answer can follow a successful write, and a new action ID can duplicate it.

Find it: `rg -n '503|502|504|uncertain|emailStart|auth/email/start' --glob '!node_modules'`

### Content stages [#content-stages]

Content quests use the shared tracker trigger: `content({ tracker })` takes the tracker and its start policy in one object, the content item supplies the completion, and the captured body is `ctx.tracker.content.body`. Republish every content quest with the 1.0 SDK.

| 0.x                                                                         | 1.0                                                                                                            |
| --------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------- |
| `content(posts, policy)`                                                    | `content({ tracker: posts, ...policy })`                                                                       |
| `completion: perContent()`                                                  | Omit `completion`: a content quest completes once per item                                                     |
| `posts: "since-start"`                                                      | `since: "window"`                                                                                              |
| `posts: "after-enrollment"` with required enrollment                        | `since: "enrollment", enrollment: "required"`                                                                  |
| No start policy                                                             | `since: "activation"`, the default                                                                             |
| `ctx.content`, `ctx.tracker.x.content`, a performance evaluator's `content` | `ctx.tracker.content.body`, with `revision` and `observation` beside it                                        |
| `relay.content.admissions`                                                  | `relay.quests.outcomes({ quest, ...filters })`                                                                 |
| `relay.content.claim({ member, content, ... })`                             | `relay.quests.claim({ actionId, quest, tracker, record, observedAt })` on a claimable outcome                  |
| `management.content.admissions`                                             | `management.trackers.triggerOutcomes(tracker, { quest, record, ...filters })`                                  |
| `management.content.approve`                                                | `management.trackers.retryOutcome(tracker, { actionId, quest, record, observedAt })`, with `review` permission |

For custom content, declare `content: { body: z.object({ ... }), metrics: [...] }` in `customSource` instead of `data`, and ingest `data: { body, metrics? }`; see [custom content](/docs/build/trackers#declare-custom-content). Admission pins the quest's release and beneficiary, and budgets use the record's occurrence time.

Old:

```text
trigger: content(posts, { posts: "after-enrollment", enrollment: "required" }),
completion: perContent(),
async evaluate({ content }) { return content!.text.includes("#launch") ? decision.accept() : decision.reject({ reason: "Include #launch." }) },
```

New:

```ts
import { content, decision, defineQuest, defineTracker, settings, x } from "@domino-sdk/relay/authoring"

const posts = defineTracker({
  key: "launch-posts", title: "Launch posts", source: x.search({ query: "#launch" }),
  window: { start: Date.parse("2026-10-01T00:00:00Z") },
})

export default defineQuest({
  key: "launch-post",
  title: "Post about the launch",
  settings: settings.object({}),
  trigger: content({ tracker: posts, since: "enrollment", enrollment: "required" }),
  rewards: [],
  evaluate: async ({ tracker }) =>
    tracker.content.body.text.includes("#launch") ? decision.accept() : decision.reject({ reason: "Include #launch." }),
})
```

Why: content quests ran their own admission pipeline, for X only. On the shared trigger they get the enrollment, historical-activity and outcome rules of every tracker quest, and custom sources get content stages too.

Find it: `rg -n 'content\(|perContent|ctx\.content|tracker\.x\.content|content\.(admissions|claim|approve|enroll)' --glob '!node_modules'`

### Evaluator protocol [#evaluator-protocol]

Every published quest bundle declares evaluator protocol 2, and Relay refuses a bundle with no protocol, or one it does not support, before evaluating it. `defineQuest()` declares it on the bundle (`protocol: 2`) and its description (`evaluatorProtocol: 2`), and Relay checks they agree. 0.x bundles declare none, so rebuild and republish every quest with the 1.0 SDK; changing a version field is not enough. A tool that writes bundles itself drops `runtimeVersion` and writes `evaluatorProtocol` from `@domino-sdk/relay/schema` in both places.

Protocol 2 adds `ctx.calendar.day(at?)`: the project-calendar day of `ctx.eligibleAt`, or of an epoch-millisecond instant, as `{ key, date, timeZone, start, end }`, where `key` includes the zone (`YYYY-MM-DD@Zone`). It is pinned per evaluation run. Use `ctx.calendar.day().key` instead of a UTC date for daily completion keys. A test that calls `evaluate()` with a hand-built context needs a calendar, which `createTestContext` and `runQuest` from `@domino-sdk/relay/testing` supply.

Old:

```text
export default { runtimeVersion: 2, describe, evaluate }
completion: keyed(({ eligibleAt }) => new Date(eligibleAt).toISOString().slice(0, 10)),
```

New:

```ts
import { keyed } from "@domino-sdk/relay/authoring"
import { evaluatorProtocol } from "@domino-sdk/relay/schema"

// The activity's project-calendar day, such as 2026-10-15@Europe/Budapest.
export const completion = keyed(({ calendar }) => calendar.day().key)
export const bundleHeader = { protocol: evaluatorProtocol }
```

Why: Relay runs each bundle with the host adapter for the context it was built against; protocol 2 is the first stable evaluator contract.

Find it: `rg -n 'runtimeVersion|evaluatorProtocol|protocol:|toISOString\(\)\.slice\(0, ?10\)' --glob '!node_modules'`

### Declared integrations [#declared-integrations]

A quest declares the provider modules whose readers it uses, `integrations: [discord, x]`, imported from `@domino-sdk/relay/authoring`. `ctx.discord` and `ctx.x` are absent unless declared: add `discord` for Discord role rewards or resource selectors, and `x` to read an X image. Wallet, member, tracker, image and review readers need no declaration. `discordRole(roleId)` is `discord.grantRole(roleId)`.

`ctx.wallet.connection()` and `ctx.wallet.solanaBalance()` are removed: use `ctx.wallet.list()` and `ctx.wallet.balance({ chain: "solana" })`, which totals every linked Solana wallet; to keep the old newest-wallet behavior, take the first Solana wallet from `ctx.wallet.list()` and pass its `address`. Both return `null` when no matching wallet is linked. Keep amounts as strings or `BigInt`, never `Number`.

Old:

```text
rewards: [discordRole("123456789012345678")],
const balance = await ctx.wallet.solanaBalance()
if (BigInt(balance.lamports) > 0n) return decision.accept()
```

New:

```ts
import { decision, defineQuest, discord, manual, once, settings } from "@domino-sdk/relay/authoring"

export default defineQuest({
  key: "hold-sol",
  title: "Hold SOL",
  settings: settings.object({}),
  integrations: [discord],
  trigger: manual(),
  completion: once(),
  rewards: [discord.grantRole("123456789012345678")],
  async evaluate(ctx) {
    const balance = await ctx.wallet.balance({ chain: "solana" })
    return balance && BigInt(balance.total) > 0n ? decision.accept() : decision.reject({ reason: "Hold SOL." })
  },
})
```

Why: an evaluator gets only the readers its quest declares, so a quest's providers are known at publication, and wallet reads cover every wallet a member links.

Find it: `rg -n 'discordRole\(|ctx\.(discord|x)\.|wallet\.(connection|solanaBalance)\(' --glob '!node_modules'`

### Wallet reads return every linked wallet [#wallet-reads-return-every-linked-wallet]

Participant and management reads expose a member's wallets only as lists. In a management member read, `member.wallet.connection` is replaced by the linked entries of `member.wallets`, and `member.wallet.observation` by `member.balanceChecks`. In `@domino-sdk/relay/schema`, `WalletConnection` and `walletConnectionSchema` are `LinkedWallet` and `linkedWalletSchema`; the single-wallet challenge and proof schemas are `walletAddressInputSchema`, `walletMessageChallengeSchema` and `walletSignatureSchema`; and `SolanaBalance` and `solanaBalanceSchema` are `walletBalanceSchema`, whose integer `total` and per-wallet `amount` replace `lamports`, and whose chain-specific `at` replaces the top-level Solana `slot`. Fixtures and proxies must include fields 1.0 responses always carry: `kind` on a member account, `wallets`, `balanceChecks`, `profile` and `identities` on member detail, `profile` on a leaderboard row, and `wallets` on an auth provider.

Old:

```text
const wallet: WalletConnection | null = detail.wallet.connection
```

New:

```ts
import type { ManagementClient } from "@domino-sdk/relay/server"
import type { LinkedWallet } from "@domino-sdk/relay/schema"

declare const management: ManagementClient

const detail = await management.members.get("member_1")
export const solana: LinkedWallet[] = detail.wallets.filter((w) => w.status === "linked" && w.chain === "solana")
```

Why: members link several wallets on several chains, and a single-wallet field showed only one.

Find it: `rg -n 'walletConnectionSchema|WalletConnection|walletChallenge(Input)?Schema|walletProofSchema|solanaBalanceSchema|SolanaBalance|lamports|\bslot\b|\.wallet\.(connection|observation)' --glob '!node_modules'`

### Typed trigger policies [#typed-trigger-policies]

A quest's trigger decides which completion and prerequisites it may have, and TypeScript and `defineQuest` check them. A bare `automatic()` quest runs once its prerequisites hold, so it needs a nonempty `requires` list and `completion: once()`; use `automatic({ tracker })` when activity supplies the completion key or streak day. A reusable `defineQuestType` may leave prerequisites to each Console instance. A content quest omits `completion`, and only a content quest can have a performance stage. A tracker handle limits `beneficiary` to its source's account roles; for a custom source, declare the role in `accounts` first.

Old:

```text
trigger: automatic(),
completion: keyed(({ now }) => String(now)),
```

New:

```ts
import { automatic, completed, once, type QuestHandle } from "@domino-sdk/relay/authoring"

declare const welcome: QuestHandle

export const policy = { trigger: automatic(), requires: [completed(welcome)], completion: once() }
```

Why: these shapes used to type-check and fail at publication; a bare automatic quest has no activity to key a completion or count a streak day.

Find it: `rg -n 'automatic\(\)|beneficiary:' --glob '!node_modules' --glob '*.{ts,tsx,js,mjs}'`

### Saved CLI mutation recovery [#saved-cli-mutation-recovery]

`domino create`, `domino quests archive` and `unarchive`, build submission and deployment publication save a recovery record under `$RELAY_CONFIG_DIR/operations` before sending. A failure that may leave work to finish carries `operation` in its JSON error, including known failures such as a checkout failure after the project was created, whose `operation.scope.project` is empty until the API assigns one. `create` and the quest lifecycle commands accept `--operation-id UUID`. Keep the directory between automation runs, and finish an interrupted command with the resume command its error names.

Old:

```text
domino create --json || domino create --json
```

New:

```sh
domino operations list --json
domino operations resume OPERATION_ID --json
```

Why: a restarted process lost the action ID of a change that may have landed, so running the command again could create a second project. See [CLI automation](/docs/reference/automation#recover-after-restarting-the-cli).

Find it: `rg -n 'domino (create|quests (archive|unarchive)|stage|deploy)\b|RELAY_CONFIG_DIR' --glob '!node_modules'`

### Handle future tracker sources [#handle-future-tracker-sources]

Published tracker definitions read `source` as an open type, so a source kind a newer Relay adds keeps its payload instead of failing the tracker list. Narrow it with `isKnownTrackerSource(source)` from `@domino-sdk/relay/schema` before reading fields such as `query` or `namespace`, and show an unknown source by its kind, read-only.

Old:

```text
if (definition.source.kind === "custom") return definition.source.namespace
```

New:

```ts
import { isKnownTrackerSource, type TrackerDefinitionResponse } from "@domino-sdk/relay/schema"

export function describeSource({ source }: TrackerDefinitionResponse) {
  if (!isKnownTrackerSource(source)) return source.kind
  return source.kind === "custom" ? source.namespace : source.kind
}
```

Why: with a closed source union, every new source kind would break apps that list trackers.

Find it: `rg -n 'source\.kind|source\.(query|namespace)' --glob '!node_modules'`

### Discord role ownership and reconciliation [#discord-role-ownership-and-reconciliation]

Relay owns the Discord role assignments it makes. A role's membership follows every accepted, unrevoked completion that grants it to that account in that server, across projects and test and live: Relay removes its own assignment when the last grant is revoked and restores a missing role while a grant stays accepted. Assignments Relay did not make, and grants without ownership evidence, such as roles granted before ownership was recorded, are left alone. A delivery has an optional `reconciliation` (`status`, `ownership`, `at`, `reason`), separate from its history. `retryRewardDelivery` on a revoked delivery reconciles without granting again. `management.discordRoles.preserve(delivery, { reason })`, with `review` permission, stops Relay from removing an assignment another bot or administrator also manages; see [ownership and recovery](/docs/build/integrations#recover-or-preserve-a-role).

Old:

```text
await management.preserveRewardRole(deliveryId, { reason })
```

New:

```ts
import type { ManagementClient } from "@domino-sdk/relay/server"

declare const management: ManagementClient

await management.discordRoles.preserve("delivery_1", { reason: "The moderation bot owns this role too" })
```

Why: role membership has to follow the completions that grant it, also when projects share a server, without removing roles someone else assigned.

Find it: `rg -n 'rewardDeliveries|retryRewardDelivery|preserveRewardRole|"grant-role"' --glob '!node_modules'`

### Prerequisite automatic outcomes [#prerequisite-automatic-outcomes]

A bare `automatic()` quest records each decision as an outcome, admitted or skipped, in `relay.quests.outcomes()` and Console. These rows have `automatic` set, null `tracker`, `record` and `observedAt`, and `claimable: false`; key them by `automatic`, and show an unknown skip code by its `reason`. Outcome pages merge them with tracker decisions, so don't reuse a cursor saved before the upgrade.

Eligibility is checked after the change that affects it commits: a points entry, a completion, a group change, campaign enrollment or publication. A points mutation's response does not include the automatic attempt it leads to; read outcomes or progress. Events that arrive while the quest is paused are not replayed on resume: an operator previews and confirms a catch-up in Console, or with `management.quests.previewAutomaticCatchUp` and `catchUpAutomatic`, with `review` permission and a reason. See [automatic outcomes](/docs/concepts/quests#outcomes-for-prerequisite-only-automatic-quests) and [recovery](/docs/use/quests#recover-automatic-quest-outcomes).

Old:

```text
const key = `${outcome.tracker}:${outcome.record}:${outcome.observedAt}`
```

New:

```ts
import type { QuestOutcome } from "@domino-sdk/relay"

export const rowKey = (outcome: QuestOutcome) =>
  outcome.automatic ?? `${outcome.tracker}:${outcome.record}:${outcome.observedAt}`
```

Why: eligibility could change with no visible decision, and events during a pause were dropped silently.

Find it: `rg -n 'quests\.outcomes|outcome\.(tracker|record)|automatic\(\)' --glob '!node_modules'`
