@domino-sdk/relay-cli 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -2,9 +2,34 @@
2
2
 
3
3
  Read the installed `@domino-sdk/relay/authoring` declarations and the project's existing quest modules. Define the requested behavior with the supported SDK helpers rather than inventing capabilities. Authored entries in relay.json resolve relative to that manifest and can select a named export with `exportName`.
4
4
 
5
+ The `discover.quests` and `discover.questTypes` arrays in relay.json select default-exported resource files. New projects use `quests/**/*.quest.ts` and `quests/**/*.quest-type.ts`. Add matching files without individual registration. Existing manifests without `discover` keep explicit-entry behavior. An explicit entry overrides the matching discovered default export; omitted providers inherit `discover.provider` for those files. Discovery does not execute authored modules on the build host, publish them, or archive omitted resources.
6
+
7
+ ## Find a snippet
8
+
9
+ Before writing a new quest, fetch the public catalog. It requires no authentication:
10
+
11
+ ```sh
12
+ curl -fsS https://snippets.domino.run/r/registry.json
13
+ ```
14
+
15
+ 1. Match the requested behavior against each item's `title`, `description`, and `categories`. Use the catalog's `name` to fetch `https://snippets.domino.run/r/<name>.json`; inspect its `docs` and `files` contents, targets, and prerequisites before installing. The live catalog is the source of available recipes, so newly published snippets need no skill update.
16
+ 2. If a recipe fits, install it from the project root containing `relay.json` and `package.json`. For example, when the catalog lists `photo-quest`:
17
+
18
+ ```sh
19
+ pnpm dlx shadcn@4.21.0 add https://snippets.domino.run/r/photo-quest.json
20
+ ```
21
+
22
+ Preserve existing customized files; adapt those in place when the recipe is already installed. Use the project's package manager equivalent when it is not pnpm.
23
+ 3. Read the installed `docs/snippets/<name>.md`. Adapt the source to the requested behavior, choose unique IDs for new quests, and configure settings, rewards, and verification prerequisites. Keep existing quest IDs stable and verification source out of the browser bundle.
24
+ 4. Confirm the source matches `discover` in `relay.json`. Move it to a matching directory for custom layouts, or add an explicit entry if discovery is disabled or unsupported by the installed CLI. Run the project's checks and `domino build --json`, then exercise the requested participant behavior using the verification workflow in the Domino skill. Installation only copies source; publication is a separate step below.
25
+
26
+ If no recipe fits or the catalog is unavailable, author with the installed SDK declarations and existing project examples. Report an unavailable catalog as unchecked, rather than claiming no matching snippet exists. Snippets are editable starting points; check compatibility with the project's installed SDK before changing package versions.
27
+
28
+ ## Publish the authored resources
29
+
5
30
  Source owns reusable quest definitions, collection slots, and deployment defaults. Console owns operator-created quest instances, their placement, and custom setting values. Keep identifiers stable. Registering a new reusable type version does not upgrade existing instances.
6
31
 
7
- Entry `settings` supplies deployment defaults. Remote custom values take precedence on subsequent deployments. A removed or incompatible setting can block the batch. Use `settingRenames` for a deliberate rename, or have the operator reset a custom value in Console. Changing source defaults does not clear overrides.
32
+ Entry `settings` supplies deployment defaults. Remote custom values take precedence on subsequent deployments. A removed or incompatible setting can block the batch. Use `settingRenames` for a deliberate rename, or have the operator reset the custom value in Console under **Quests**. Changing source defaults does not clear overrides.
8
33
 
9
34
  Use `domino build --json` or `domino deploy --dry-run --json` for offline bundling. These do not validate effective remote settings. Use `domino deploy --preview --json` to resolve and validate the remote batch without publishing. Check the selected scope before publication.
10
35
 
@@ -13,3 +38,75 @@ Use `domino deploy --environment test --json` when test publication is part of t
13
38
  For human live review, export `domino deploy --dry-run --out deployment.json` and present that bundle for Console approval. This publishes community behavior, not the participant application. Existing attempts retain their pinned releases and promised rewards.
14
39
 
15
40
  Fixture providers simulate verification. `workers-ai` requires a configured service binding. A fixture success demonstrates the flow but does not establish that a real external activity was verified.
41
+
42
+ ## Leaderboard definitions
43
+
44
+ Default-export `defineLeaderboard({ id, title, balance, score, access, group })` from `@domino-sdk/relay/authoring`. Add `{ "entry": "src/leaderboard.ts" }` to `leaderboards` in `relay.json`; optional `exportName` selects a named export. Deploy through the existing project bundle workflow. Definitions and standings previews appear in Console Releases before live publication.
45
+
46
+ ```ts
47
+ import { defineLeaderboard } from "@domino-sdk/relay/authoring";
48
+
49
+ export default defineLeaderboard({
50
+ id: "weekly",
51
+ title: "Weekly contributors",
52
+ balance: "community",
53
+ access: "participants",
54
+ score: {
55
+ kind: "earned",
56
+ window: { kind: "calendar", period: "week", timeZone: "Europe/Budapest" },
57
+ },
58
+ });
59
+ ```
60
+
61
+ Score defaults to `{ kind: "balance" }`. Use `{ kind: "earned", window, quests? }` or `{ kind: "net", window }` for period calculations. Windows are `all-time`, `fixed` with epoch-millisecond `start`/`end`, `rolling` with `days`, or `calendar` with `period: "day" | "week" | "month"` and an IANA `timeZone`. Only earned scores can filter quest IDs. Access defaults to `participants`; `public` allows anonymous reads through the configured project endpoint. A named `group` narrows eligibility.
62
+
63
+ Leaderboards and points remain project/environment-scoped. Custom scoring runs before points ingestion. Management keys with `points` permission can award, spend, adjust, reverse, or set totals through `management.points.update`. Every command requires an action ID and reason; exact retries return the original result. `set` requires the current `expectedTotal` and explicit `earned`/`spend` classification. Reversals reference an original award and correct its original period. Do not implement negative clamping, direct database writes, browser awards, shared organization balances, or automatic top-rank prizes.
64
+
65
+ ## External points
66
+
67
+ Run this on a trusted backend with a management key scoped to the project and environment. The member must already be a project participant. Quest awards to the board's balance count automatically; use this API for externally verified activity or corrections.
68
+
69
+ ```ts
70
+ import { createManagementClient } from "@domino-sdk/relay-cli";
71
+
72
+ const management = createManagementClient({
73
+ baseUrl: "https://relay.domino.run",
74
+ organization: "my-organization",
75
+ project: "community",
76
+ environment: "test",
77
+ getToken: async () => process.env.DOMINO_MANAGEMENT_KEY!,
78
+ });
79
+
80
+ await management.points.update({
81
+ kind: "award",
82
+ actionId: "external-activity-42",
83
+ member: "member_existing",
84
+ balance: "community",
85
+ amount: 100,
86
+ reason: "Verified external activity 42",
87
+ });
88
+ ```
89
+
90
+ Persist the action ID with the source event and reuse the same payload after an uncertain response. Amounts are safe whole-number integers. Backdating uses `effectiveAt` in epoch milliseconds; future dates fail. Deductions cannot make the balance negative or consume reserved points. Read operations such as `management.points.balance(member, balance)` and `history(member, balance)` require `read` permission.
91
+
92
+ For externally owned totals, dedicate a balance to that source. Read its current total before a `set` command; on an `expectedTotal` conflict, read again and decide whether to issue a new command with a new action ID. A set replaces the whole balance, including any quest awards written there.
93
+
94
+ ## Verify a leaderboard
95
+
96
+ Publish to test and read through the participant endpoint described in [participant API](https://console.domino.run/docs/reference/participant-api#leaderboard-reads). Award known points to existing test participants and verify the expected ordering, shared ties, signed-in `me`, and access after sign-out. Confirm that retrying one external event does not award twice. For a period board, check an entry inside and outside the window. Report the tested project, environment, board, and observed scores.
97
+
98
+ Treat top-rank prizes as a separate reward workflow with an explicit tie policy and finalization time. Board exclusion changes eligibility only; Console or management calls with `publish` permission handle exclusions and named group membership.
99
+
100
+ ## Referral programs
101
+
102
+ For `defineReferral`, invitation qualification, fixed rewards, and selective inviter bonuses, read [referrals](referrals.md). Register modules in the manifest's `referrals` array; they publish atomically with quests and leaderboards. Referral bonuses use the existing project points ledger.
103
+
104
+ ## Verify Discord membership
105
+
106
+ Use the `discord-membership` snippet for a server membership check, with an optional role requirement. An organization administrator first connects the project's server in Console **Integrations**. Declare `integrations: ["discord"]` on the quest and call `await ctx.discord.member()` inside `evaluate`. There is no server ID setting. The result is `null` for absent membership, or `{ roles, joinedAt, pending }`. Author code decides eligibility and the quest's declared rewards.
107
+
108
+ The platform supplies a read-only client bound to the project's configured server and the participant's Discord identity. Quest code cannot supply a user ID, server ID, access token, or arbitrary URL. Discord permission failures and outages fail verification; they are not evidence that the participant is ineligible. Bot access problems appear in Console Integrations. Participants signing in with Discord need no extra permission flow; members using another login link their Discord identity once.
109
+
110
+ Successful reads, including absent membership, are stored with the account revision, integration revision, server, and observation time. Recovery reuses captured facts. Changing either connection invalidates unfinished reads. After a failed attempt, start a new submission if a connection changed. Membership screening (`pending`) and role requirements remain author decisions.
111
+
112
+ The campaign starter includes participant identity controls. Existing apps use the same APIs through `createParticipantBackend`; see [participant API](participant-api.md#connected-accounts). The managed bot is configured by the platform, never by a quest author. Do not invent message, reaction, or write capabilities: `member` is the only Discord operation currently available. X remains unavailable.
@@ -1,5 +1,7 @@
1
1
  # Existing apps
2
2
 
3
+ Before the first implementation, follow the Domino skill's existing-app campaign confirmation procedure. Ask for missing campaign basics, prepare the campaign and integration proposal, and obtain the owner's confirmation. Preserve the app's current design and components; brand discovery and visual approval are not prerequisites. Reuse an already confirmed brief in later sessions.
4
+
3
5
  Inspect the app's framework, package manager, workspace boundaries, authentication, and deployment scripts. Identify where the requested participant experience belongs. Keep its repository, UI conventions, login, and hosting.
4
6
 
5
7
  Use the Domino project selected by the user. If no remote project exists, create it in Console. `domino create` provisions a hosted repository and is not the existing-app integration path.
@@ -1,17 +1,29 @@
1
1
  # Hosted projects
2
2
 
3
+ Before the first implementation, follow the Domino skill's intake, discovery, and confirmation procedure. Ask for missing brand and campaign basics and wait for answers before research or design. Then prepare a concrete proposal and obtain the owner's confirmation. Reuse an already confirmed brief in later sessions.
4
+
3
5
  If Console already created the project and repository, run `domino checkout <project>`. Use `domino create <name>` only when the requested hosted project does not exist. Both require authentication. Use `domino whoami --json` to inspect accessible projects when the target is unclear.
4
6
 
5
7
  For a failure after remote project creation, inspect the existing project's state before repeating creation. Keep partially created local directories intact while recovering. Checkout accepts `--directory` for a fresh destination.
6
8
 
7
9
  Install dependencies from the lockfile. The starter includes pinned SDK, CLI, and local-runtime archives in `.domino`; upgrade them through a platform package release rather than modifying their contents. Read CAMPAIGN.md for the owner's brief.
8
10
 
11
+ The starter is a static Vite app using React, TanStack Router file-based routes, and Tailwind CSS v4. Its look is neutral on purpose (greyscale color tokens in `src/styles.css`, no logo or accent) and it ships sample quests and copy that introduce Domino; replace them with the owner's campaign. Its AGENTS.md maps the source and describes the `src/relay` hooks for session, quest state, submission, and recovery. Build the interface on those hooks and keep their recovery behavior.
12
+
13
+ The starter's relay.json declares discovery patterns for `quests/**/*.quest.ts` and `quests/**/*.quest-type.ts`. Add a matching default-exported file to include it in builds and development sync. Installed snippets use these paths. Use explicit entries only for named exports or per-resource overrides.
14
+
9
15
  Run `domino dev` to start the app with hosted test data and real sign-in. Share its HTTPS preview URL. The CLI prepares the tunnel connector automatically. The URL goes offline when development stops; use `domino stage` for a preview that stays online. Use `domino dev --offline --review-mode fixture-pass` only when you explicitly need an isolated runtime with simulated photo verification. This is not evidence that an external action happened.
10
16
 
11
17
  Use `domino check` and the app's build script to verify source. Exercise the requested participant flow in the running app before staging.
12
18
 
19
+ For leaderboards, register authored modules in the manifest's `leaderboards` list. `domino check` bundles them even when the project has no quests. `domino dev` synchronizes their definitions with the other project resources. Verify signed-in rank reads through the participant proxy; a successful build alone does not establish access or scoring behavior.
20
+
13
21
  When the task includes sharing a preview, commit the intended changes and push using the repository's configured remote. Run `domino stage --json` after the push. The command builds the exact pushed commit and returns its staging URL. Git pushes alone save source; they do not activate a preview or publish live.
14
22
 
15
23
  If stage submission loses its response, repeat the printed commit and request ID. Use `domino builds --json` and `domino logs <build-id> --json` to recover progress after disconnecting. A failed build leaves the previous preview active. Report the URL returned for the successful build rather than constructing one.
16
24
 
17
25
  Cloud staging supports the starter's static app contract. It does not host arbitrary SSR or customer Worker code. New staging builds use the same project test data as development and real participant sign-in. Existing previews created before this update remain simulated until rebuilt. Test data does not imply that external provider operations are simulated. Hosted app live activation remains a separate platform milestone.
26
+
27
+ For referral programs, register authored modules in `referrals`. `domino check` and `domino dev` include them even without quests. Preserve invitation codes across the app's sign-in redirect and verify acceptance through the configured participant proxy before any quest completes. See [referrals](referrals.md).
28
+
29
+ Quests declare `integrations: ["discord"]`; Relay derives the participant identities required by each quest. The campaign starter renders account controls from the quest view’s `connections` field. The Discord membership snippet needs a project Discord integration configured in Console. Sign in with Discord (or link its identity) and check membership before staging; offline photo fixtures do not simulate Discord. See [connected accounts](participant-api.md#connected-accounts).
@@ -62,3 +62,48 @@ Submission results are `submitted` with an attempt, or `quest-updated` with `lat
62
62
  `controller.status()` returns `empty`, `needs-review`, `pending` with upload/claim stage, or `submitted` with attempt ID. `needs-review` means the saved draft needs the participant's review, not that a moderator is reviewing it. Use `resume()` to recover a saved operation after reload or a lost response. Use `relay.quests.retry(view)` only for a failed verification attempt. Do not invent a new submission to recover an uncertain response.
63
63
 
64
64
  Use `relay.me.observeProgress(listener)` for balances, completion and rewards. Dispose observers on unmount. Sign-in changes clear old participant observations. Render 401 as a sign-in request, 403 as an access issue, and transient failures with a retry that preserves the saved draft. IndexedDB and Web Locks must be available for durable browser submissions.
65
+
66
+ ## Leaderboard reads
67
+
68
+ ```ts
69
+ import { createBrowserRelayClient } from "@domino-sdk/relay/browser";
70
+
71
+ const relay = createBrowserRelayClient({ baseUrl: "/relay" });
72
+ const boards = await relay.leaderboards.list();
73
+ const page = await relay.leaderboards.standings("weekly", { limit: 25 });
74
+ for (const row of page.items) {
75
+ console.log(row.participant, row.rank, row.name, row.score);
76
+ }
77
+ console.log("My rank:", page.me?.rank);
78
+ console.log("Nearby:", page.nearby);
79
+ if (page.nextCursor) {
80
+ const nextPage = await relay.leaderboards.standings("weekly", {
81
+ limit: 25,
82
+ cursor: page.nextCursor,
83
+ });
84
+ }
85
+ ```
86
+
87
+ Use `client.leaderboards.list()` and `client.leaderboards.standings(id, { limit, cursor })` for project-scoped standings. Results include `items`, `me`, `nearby`, resolved `period`, `calculatedAt`, `lastEntryAt`, and `nextCursor`. Equal scores share ranks. Restart pagination on HTTP 409; scores or eligibility may have changed. Private boards require a participant session. Public boards are explicitly published with public access and expose generated board-specific display aliases, not account identities. Both the participant proxy and existing-app bridge support these GET routes.
88
+
89
+ Never expose management credentials to implement a leaderboard. Points writes and moderation use the scoped management API from trusted servers or Console. Cross-project balances, prize delivery, and finalized competition results are not supported by the leaderboard API.
90
+
91
+ Render `row.rank` as returned, rather than using the array index; ties share ranks such as `1, 2, 2, 4`. Use `row.participant` as the board-specific row key. `me` is null for anonymous or unranked participants, and `nearby` contains the member plus up to two rows on either side. No qualifying points means no ranked row. Display an empty state when `items` is empty.
92
+
93
+ Cursors expire after 15 minutes. On HTTP 409, discard accumulated pages and fetch the first page again. `calculatedAt` is the calculation time; `lastEntryAt` is the latest received balance entry, not evidence that an external provider has synchronized. Public rows contain generated names, not account names or avatars.
94
+
95
+ ## Referral invitations and progress
96
+
97
+ Use `relay.referrals.summary()` for the signed-in member's invitation code, attributed and qualified counts, milestone progress, and earned/settled/outstanding accounts. Use `accept({ code, actionId })` after sign-in and before the first quest completion. Preserve the code through sign-in and reuse input on uncertain retries. `history(before?)` reads paginated payout activity. Read [referrals](referrals.md) for authoring, qualification, corrections, and verification. The participant proxy and existing-app bridge route these operations with session-derived identity.
98
+
99
+ ## Connected accounts
100
+
101
+ Project integrations and participant identities are separate. An organization administrator connects Discord in Console **Integrations**, selects a server, and installs the managed bot. Each project/environment has one server binding. See [project integrations](https://console.domino.run/docs/build/integrations).
102
+
103
+ Participants who sign in with Discord already have the identity needed for verification. If they use another login, keep their current member signed in, call `relay.auth.start("discord", "connect")`, and navigate to the returned `url`. This requests only `identify`, not membership-reading access. `relay.auth.accounts()` lists the provider's configuration, link status, subject, and identity revision. It never returns credentials. Quest views expose required participant identities in `connections`, derived from their declared integrations.
104
+
105
+ To choose a different identity, first call `relay.auth.disconnect("discord")`. This unlinks the active Discord identity across the member's organization and environment. Ownership, sign-in eligibility, and earned rewards stay intact; another member cannot claim the identity. It does not disconnect the project's Discord server. Both operations retain historical verification facts.
106
+
107
+ The campaign starter includes these account controls. Apps using `createParticipantBackend` retain their own login and use the same APIs. The adapter preserves OAuth binding cookies and its session during return navigation; the application's own login cookie must also support top-level OAuth returns. If the app session changes during consent, start connection again.
108
+
109
+ After changing an account or integration during a failed attempt, use the quest controller's `discard()` to clear the saved failed submission, then submit again. The starter labels this **Start a new submission**. Discard is allowed only after a confirmed failed or rejected attempt. It keeps server history and cannot discard an active or accepted attempt. Use `resume()` when the connection has not changed to preserve captured facts.
@@ -1,15 +1,64 @@
1
- # Build a referral campaign
1
+ # Build a referral program
2
2
 
3
- Read `domino capabilities referrals --json` and `domino capabilities cross-participant-rewards --json` before committing to the reward design. Domino currently supports participant identity, quest completion, points for that participant, and human review. It does not implement referral attribution, inviter-targeted rewards, or queue ranking.
3
+ Run `domino capabilities referrals --json` against the installed release. Native referrals own invitation codes, project-scoped attribution, qualification, fixed rewards, milestones, and one-level inviter bonuses. The participant app owns invitation presentation and retaining the code through sign-in. Use the existing project ledger and publication flow.
4
4
 
5
- ## Supported division of work
5
+ ## Author and publish
6
6
 
7
- Your application backend owns a referral record with `inviteCode`, `inviterMemberId`, `inviteeMemberId`, `project`, `environment`, `createdAt`, and `status`. Generate opaque invite codes server-side. Authenticate both participants through their own sessions. Resolve the invitee from the verified session, never a browser-supplied member ID. Reject self-referrals and enforce a unique invitee attribution within the campaign. Record the terms and time of attribution so retries cannot replace the inviter.
7
+ ```ts
8
+ import { defineReferral, points } from "@domino-sdk/relay/authoring";
8
9
 
9
- The participant app can capture an invite code from a landing URL and submit it to that backend after sign-in. Treat the captured code as a claim until validated. Your backend can present pending claims to a human reviewer. Keep attribution storage and review in the application until a supported Domino attribution contract exists.
10
+ export default defineReferral({
11
+ id: "friends",
12
+ title: "Invite friends",
13
+ qualification: { kind: "quest", quest: "welcome" },
14
+ rewards: { inviter: [points("community", 25)] },
15
+ milestones: [
16
+ { id: "five", count: 5, rewards: [points("community", 250)] },
17
+ ],
18
+ bonuses: [
19
+ { id: "share", balance: "community", basisPoints: 1000, quests: ["welcome"] },
20
+ ],
21
+ });
22
+ ```
10
23
 
11
- Domino can independently reward the invitee for a supported quest, such as joining the campaign. A Domino quest reward cannot credit the inviter instead. If inviter rewards are mandatory, build and operate an application-owned reward ledger with idempotent grants, or defer that part of the campaign. Do not describe manual attribution as automatic verification. Quiz answers are constrained choice evidence, not a general arbitrary JSON submission API.
24
+ Register `{ "entry": "src/referrals.ts" }` in `relay.json` under `referrals`; `exportName` selects a named export. `domino check` bundles definitions without executing author code locally. Preview and publish with the existing project deployment commands. Live publication uses the reviewed Console bundle. Omitted programs remain published; `enabled: false` stops future qualification and bonuses. Keep program and rule IDs stable.
12
25
 
13
- ## Verify with real people
26
+ Choose qualification explicitly: `signup` qualifies when a signed-in participant accepts an invitation; `quest` requires the named quest; `external` requires the trusted management qualification command. Each program counts distinct qualified friends. Existing points and tier rewards are accepted for inviter, invitee, and milestones. Each referral tier reward uses its own retained staff-handover entitlement. Different friends can earn separate items; requalification reuses the original entitlement.
14
27
 
15
- Have two people sign in through the app and follow an invite. Verify the stored attribution, duplicate handling, and self-referral rejection. Check that the invitee's quest completion persists after refresh. Show the owner precisely which rewards Domino executes and which remain application-owned. A campaign is not complete if promised inviter rewards have no executor.
28
+ `basisPoints: 1000` means an additional 10%; the invitee retains their full award. Omitted quest filters include all quests in that balance. Optional `inviterGroup` restricts eligibility using the same named groups as leaderboards. Optional `durationDays` and `maxPoints` limit each invited friend's bonus. Fractions accumulate per friend, rule, and balance. Percentage rules must not overlap for the same source; group names cannot prove disjointness. Fixed rewards and milestones may coexist with a bonus.
29
+
30
+ ## Integrate the participant flow
31
+
32
+ ```ts
33
+ import { createBrowserRelayClient } from "@domino-sdk/relay/browser";
34
+
35
+ const relay = createBrowserRelayClient({ baseUrl: "/relay" });
36
+ const summary = await relay.referrals.summary();
37
+ const invitation = new URL("https://app.example.com/join");
38
+ invitation.searchParams.set("ref", summary.code);
39
+
40
+ // After the invited friend signs in, using the code retained by the app:
41
+ const code = "ref_code_from_invitation";
42
+ await relay.referrals.accept({ code, actionId: "accept-invitation" });
43
+ const activity = await relay.referrals.history();
44
+ ```
45
+
46
+ Use the configured participant proxy or identity bridge. Domino derives the invited member from their authenticated session. Accept before their first completed quest. The first accepted inviter is permanent; self-referrals, cycles, and cross-project codes fail. Link visits alone do not establish attribution. Retry uncertain requests with the same action ID and payload. Do not discard the retained invitation until acceptance succeeds or the user dismisses a terminal error.
47
+
48
+ Render attributed and per-program qualified counts, milestone progress, and earned/settled/outstanding point accounts. Settled points can include amounts offset against correction debt. Fetch older history with `referrals.history(activity.next)` when `next` is non-null. These reads reveal no invitee identities or detailed activity.
49
+
50
+ ## External activity and moderation
51
+
52
+ On a trusted backend, construct `createManagementClient` from `@domino-sdk/relay-cli` with the project/environment scope and server key. Use `management.referrals.control({ kind: "qualify", actionId, member, program, qualified: true, reason })` after verifying an external condition. Use the same command with `qualified: false` for invalidation. These commands require `publish`; reads require `read`.
53
+
54
+ External `management.points.update` awards generate a bonus only with both `referralEligible: true` and a matching bonus rule with `external: true`. They require `points` permission. Preserve the source event's action ID. Source points and referral bonuses are atomic. Adjustments, absolute totals, spending, and referral payouts do not generate bonuses.
55
+
56
+ Use `control({ kind: "suspend", actionId, member, suspended, reason })` to stop or resume future payouts while preserving attribution. Console Referrals exposes these controls, qualifications, payment history, and outstanding corrections. No missed payouts accumulate during suspension or ineligibility.
57
+
58
+ Reversals use the original rate. A fully reversed qualifying quest award revokes qualification; non-point invalidation requires staff action. Correct available points first and offset outstanding corrections against future referral payouts in that balance. Ordinary future earnings are not seized. Restore the original fixed/milestone entitlement when eligibility returns, cancelling correction debt before restoring recovered points. Do not issue duplicate rewards. Invalidated tier entitlements require staff review before handover; `resolve-reward` clears that review after the original reward has been reconciled.
59
+
60
+ New rates and eligibility apply when an award is accepted, even if its effective date is backdated. Qualifying quest points count; earlier earnings do not. Requalification does not replay past percentage earnings. Fixed rewards already issued retain their payload and milestone threshold.
61
+
62
+ ## Prove the flow
63
+
64
+ Use two signed-in test participants. Share and accept a code, complete the qualifying quest, and verify both balances and qualified counts after reload. Repeat the acceptance and source event to prove deduplication. Check self-referral rejection, a second inviter, an ineligible source, suspension, and a reversal after the inviter spends their bonus. Verify desktop and mobile UI states, including unqualified and outstanding-correction states. Report the project, environment, program, observed balances, and any unverified external condition. Simulated quest providers prove the flow, not the external activity.