giving the AI friends a bit of reality check

This commit is contained in:
Jędrzej Stuczyński
2026-05-27 17:29:18 +01:00
committed by Yana Matrosova
parent 4f51e50fb1
commit c0a1c77709
5 changed files with 43 additions and 52 deletions
@@ -14,9 +14,8 @@ This design covers the wallet UI, the hooks/mocks layer, Storybook structure, an
- UI built from Figma designs.
**Non-Goals:**
- Implementing the smart-contract changes themselves (family key/delegation, `UpdateFamily`) — those are contract-side dependencies tracked separately.
- Reward/redemption flows tied to delegation (explicitly V2 per NYM-1217).
- Real on-chain wiring of the family-key and edit paths before the contract supports them (mock-backed until then).
- Implementing the contract-side `UpdateFamily` handler - it lands in a separate contract change that this branch rebases onto before merge.
- Owner-acts-for-node behaviour (V2 per NYM-1217): the future capability for the family owner to perform actions on member nodes, plus any reward/redemption flows tied to it. V1 acceptance is a pure membership record.
## Decisions
@@ -39,11 +38,11 @@ The mock lives in `src/context/mocks/families.tsx` with fixtures in a co-located
- **Errors**: a typed error set mirroring the contract (`InvalidFamilyCreationFee`, `FamilyNameAlreadyTaken`, `FamilyNameTooLong`, `EmptyFamilyName`, `SenderAlreadyOwnsAFamily`, `NodeAlreadyInFamily`, `NodeDoesntExist`, `PendingInvitationAlreadyExists`, `ZeroInvitationValidity`, `InvitationExpired`, `InvitationNotFound`, `FamilyNotEmpty`, `SenderDoesntControlNode`, `NodeNotMemberOfFamily`, etc.) so warning/error states are reachable from mocked calls.
- **Events**: mock execute returns carry the spec's event names/attributes (`family_creation`, `family_disband`, `family_invitation`, `family_invitation_revoked/accepted/rejected`, `family_member_left/kicked`, `family_node_unbond_cleanup`) so any UI/indexer assertions can verify them.
### D4: Member-status derivation
The four UI statuses map to contract reads: **Pending** = `GetPendingInvitationsForFamilyPaged` (carry the `expired` flag), **Joined** = `GetFamilyMembersPaged`, **Rejected** = `GetPastInvitationsForFamilyPaged` filtered to `Rejected` status, **Removed** = `GetPastMembersForFamilyPaged` (left/kicked) plus `Revoked` past invitations where relevant. The derivation lives in a selector hook so both UI and tests share one definition.
### D4: Member-list sections map 1:1 to contract queries (one row per record)
The four UI sections each correspond to a distinct contract query, paginating independently via its `start_after` cursor. **Pending**: rows from `GetPendingInvitationsForFamilyPaged`, each carrying the `expired` flag. **Joined**: rows from `GetFamilyMembersPaged`. **Rejected**: rows from `GetPastInvitationsForFamilyPaged` filtered to `Rejected` status. **Removed**: rows from `GetPastMembersForFamilyPaged` (covers both left and kicked). `Revoked` past invitations are owner-side actions and are NOT shown in the member list. Because the contract stores per-`(family, node)` archive records that accumulate (a node may be invited, kicked, re-invited, etc. arbitrarily many times), a single node MAY legitimately appear in more than one section - each row represents a record, not a node. The aggregator hook is therefore a thin pass-through (queries → named sections), not a priority-cascade derivation; UI clarity comes from per-section headings + record timestamps, not from collapsing history.
### D5: Family tab is always visible; family key is standalone
The Family tab renders for **every** wallet account (not gated on owning a family or controlling a node), so any account can start a family: it shows the create entry point when the account owns no family and the management surface when it does. The family key produced on creation is a **standalone** key, modelled as an opaque value isolated behind the `createFamily`/`acceptFamilyInvitation` request boundary.
### D5: Family tab is always visible; UI identifies families by name only
The Family tab renders for **every** wallet account (not gated on owning a family or controlling a node), so any account can start a family: it shows the create entry point when the account owns no family and the management surface when it does. `family_id` is internal only - the UI identifies families by name (globally unique among live families after normalisation) and shows the owner address as supplementary trust context wherever invites are displayed. Names are released for reuse when a family is disbanded, so a past archived record's name may not match the family currently holding that name.
### D6: Large lists are paginated via the contract's exclusive `start_after` cursor
Member lists and invitation archives use the contract's cursor pagination: each page passes `start_after` (exclusive) and reads `start_next_after` from the response to fetch the next page, with the contract's default limit of 50 (max 100). The TanStack Query read hooks expose this as incremental/infinite pagination; the mock honours the same cursor semantics so paging is exercised without a chain.
@@ -61,19 +60,17 @@ The UI reads `create_family_fee`, `family_name_length_limit`, and `family_descri
## Risks / Trade-offs
- **[Family key / delegation not in contract spec]** → Model the standalone family key as an opaque value in types/mocks and isolate it behind the `createFamily`/`acceptFamilyInvitation` boundary, so a later contract decision changes only the request layer, not the UI.
- **[No `UpdateFamily` edit handler in contract spec]** → Build the edit UI + mock path now; gate real submission behind a feature check so it is dark until the contract adds the handler.
- **[Status derivation from archives is subtle]** (Rejected vs Revoked vs Removed) → Centralize in one selector hook with unit tests covering each archive→status mapping.
- **[`UpdateFamily` lands in a separate contract change]** → Build the edit UI + mock against the decided shape (see Resolved); verify on rebase per task 9.5 and reconcile any drift in the request binding, mock execute, and TS types. No feature flag needed since the wallet branch only merges after the contract change lands.
- **[Same node may appear in multiple sections]** (e.g., currently Joined and previously Removed) → record timestamps and clear section headings must make the overlap read as history rather than as a duplicate row; aggregator hook is a pass-through, so the risk is purely UX, not data correctness.
- **[Playwright-vs-Tauri divergence]** → Storybook flows test UI logic against mocks, not the real IPC bridge; a thin set of manual/native smoke checks should still cover Tauri wiring before release.
- **[Fresh Storybook conventions]** → Establish the `withFamiliesMock` decorator and naming once, up front, to avoid per-story drift.
## Migration Plan
Additive only — new tab, context, requests, types, stories, tests. No existing wallet behavior changes. The Family tab is always visible, so rollout is gated only by a feature flag for the contract-dependent edit/key paths (which ship dark until the contract dependencies land). Rollback is removal of the tab entry point.
Additive only — new tab, context, requests, types, stories, tests. No existing wallet behavior changes. This branch rebases onto the `UpdateFamily` contract change before merging, so the edit path is real (not feature-flagged) at ship time. Rollback is removal of the tab entry point.
## Open Questions
- Will the contract add an `UpdateFamily` handler for NYM-1211 edits, and what is its message shape / auth?
- Figma file/frame URLs for each component and page (to be supplied at apply time via Figma MCP).
_Resolved:_ family key is **standalone**; the Family tab is **always visible**; large lists are **paginated via the contract's `start_after` cursor** (default 50, max 100).
_Resolved:_ no family key concept in V1 (acceptance is a pure membership record; owner-acts-for-node is V2 per NYM-1217); `family_id` is **internal**, the UI identifies families by **name**; names are unique among **live** families only (released for reuse on disband); the Family tab is **always visible**; large lists are **paginated via the contract's `start_after` cursor** (default 50, max 100); the **`UpdateFamily` message shape** is `ExecuteMsg::UpdateFamily { updated_name: Option<String>, updated_description: Option<String> }` with `None` meaning "field unchanged" and `Some(_)` meaning "set to this value", sender must be the family owner; this lands in a separate contract change and is verified on rebase per task 9.5.
@@ -16,7 +16,7 @@ Node Families is a new on-chain capability (see the `node-families-contract` spe
**Node operator flows (NYM-12161219)**
- View incoming **invites per node** (multi-node aware): family name, inviting owner, expiry/TTL; expired invites shown as non-actionable.
- **Accept** an invite (`AcceptFamilyInvitation`) → node moves to Joined; **delegate control to the family key** on acceptance.
- **Accept** an invite (`AcceptFamilyInvitation`) → node moves to Joined. V1 acceptance is a pure membership record; owner-acts-for-node behaviour (where the family owner could perform actions on member nodes) is V2 per NYM-1217 and out of scope here.
- **Reject** an invite (`RejectFamilyInvitation`, confirmation) → no longer shown, node reflected as Rejected.
- **Leave** a family (`LeaveFamily`, confirmation) → removed from member list; can subsequently receive/accept new invites.
@@ -26,9 +26,8 @@ Node Families is a new on-chain capability (see the `node-families-contract` spe
- **Tests**: Storybook interaction tests, Playwright end-to-end flows, and hook/integration tests against the mocks.
- UI implemented from **Figma** (designs supplied via Figma MCP during apply).
**Contract dependencies — NOT covered by the current `node-families-contract` spec** (see Impact; flagged for resolution):
- **Family key (standalone) / delegation of node control** (NYM-1210, NYM-1217): the contract spec models ownership as `info.sender` and acceptance as a membership record only — there is no key-generation or control-delegation mechanism. The family key is a **standalone** key.
- **Edit name/description after creation** (NYM-1211): the contract spec has `CreateFamily` (carries name/description) and `UpdateConfig` (admin) but **no `UpdateFamily` edit handler**.
**Contract dependencies** (landing in a separate contract change; this branch rebases onto it before merge):
- **Edit name/description after creation** (NYM-1211): the contract spec has `CreateFamily` (carries name/description) and `UpdateConfig` (admin) but no `UpdateFamily` edit handler yet. The contract team will add it; the wallet builds against an assumed message shape and verifies it on rebase (see design.md Open Questions).
## Capabilities
@@ -47,5 +46,5 @@ Node Families is a new on-chain capability (see the `node-families-contract` spe
- **Mocks**: a faithful `node-families-contract` mock under `src/context/mocks` (provider + `families.fixtures.ts`) derived from `openspec/specs/node-families-contract/spec.md`, covering its full surface — Config, all data types, every execute msg and query (with pagination), enforced invariants, the typed error set, and emitted events. Follows the existing `mocks/bonding.tsx` convention.
- **Storybook**: new stories tree for components → pages → flows.
- **Tests**: Playwright e2e specs; Jest/RTL hook + integration tests against mocks; Storybook interaction tests.
- **Dependencies / blockers**: family-key/delegation and `UpdateFamily` edit are not in the current contract spec — UI will be built against mocked behavior and these must be added contract-side (root `node-families-contract`) before real wiring. Creation fee is configurable on-chain (not a hardcoded 100 NYM); UI must read it from config.
- **Dependencies / blockers**: `UpdateFamily` edit lands in a separate contract change; this wallet branch will rebase onto that change before merge, and the edit path swaps from the mock to the real IPC binding at rebase time (verified per task 9.5). Creation fee is configurable on-chain (not a hardcoded 100 NYM); UI must read it from config.
- **External**: Figma designs (via Figma MCP) required during implementation.
@@ -16,18 +16,14 @@ The wallet SHALL display, in the Family tab, the pending family invitations addr
- **WHEN** the operator controls more than one node, each with different invitations
- **THEN** the wallet groups invitations under their respective node and shows each node's distinct invite state
### Requirement: Node operator can accept an invite and delegate control to the family key
### Requirement: Node operator can accept an invite
The wallet SHALL let the operator accept a pending, not-yet-expired invitation from the invite view, triggering `AcceptFamilyInvitation { family_id, node_id }`. On success the wallet MUST show a confirmation and the node MUST appear as **Joined** in the family member list. Acceptance SHALL delegate control of the node to the family key; the delegation mechanism depends on the `node-families-contract` adding key/delegation support (a flagged dependency) and SHALL operate against mocked behavior until then. Accepting an expired invitation MUST be prevented (`InvitationExpired`).
The wallet SHALL let the operator accept a pending, not-yet-expired invitation from the invite view, triggering `AcceptFamilyInvitation { family_id, node_id }`. On success the wallet MUST show a confirmation and the node MUST appear as **Joined** in the family member list. In V1 acceptance records membership only; the family owner gains no control over the node itself (owner-acts-for-node is V2 per NYM-1217). Accepting an expired invitation MUST be prevented (`InvitationExpired`).
#### Scenario: Successful acceptance
- **WHEN** the operator accepts a not-yet-expired invitation for a node they control
- **THEN** `AcceptFamilyInvitation` is triggered, a confirmation is shown, and the node is reflected as Joined
#### Scenario: Acceptance delegates node control to the family key
- **WHEN** an invitation is accepted successfully
- **THEN** control of the node is delegated to the family key
#### Scenario: Expired invitation cannot be accepted
- **WHEN** the operator attempts to accept an invitation whose `expired` flag is true
- **THEN** the wallet prevents acceptance and surfaces an expired error
@@ -28,17 +28,9 @@ The wallet SHALL allow an eligible user to create a family by submitting a name
- **WHEN** creation fails with `InvalidFamilyCreationFee` or `InvalidDeposit`
- **THEN** the wallet shows a clear fee error and the family is not created
### Requirement: A standalone family key is generated on creation
On successful family creation the wallet SHALL generate a **standalone** family key and present it to the owner. The exact key mechanism depends on the `node-families-contract` adding key/delegation support; until then the wallet SHALL treat the family key as an opaque value backed by mocked behavior.
#### Scenario: Standalone family key presented on creation
- **WHEN** a family is created successfully
- **THEN** the wallet generates and displays the associated standalone family key to the owner
### Requirement: Family owner can add and edit the family name and description
The wallet SHALL let the owner set a name and description on creation and edit either after creation. Inputs MUST be validated against the contract byte-length limits (`Config::family_name_length_limit`, `Config::family_description_length_limit`) measured in bytes, and MUST be sanitised so that scripts, control characters, and injection attempts are neutralised before submission. Over-limit input MUST be surfaced with an inline error and MUST NOT be submitted. Editing depends on a contract `UpdateFamily` handler (a flagged contract dependency); until available the edit path SHALL operate against mocked behavior.
The wallet SHALL let the owner set a name and description on creation and edit either after creation. Inputs MUST be validated against the contract byte-length limits (`Config::family_name_length_limit`, `Config::family_description_length_limit`) measured in bytes, and MUST be sanitised so that scripts, control characters, and injection attempts are neutralised before submission. Over-limit input MUST be surfaced with an inline error and MUST NOT be submitted. Editing uses the contract's `UpdateFamily` handler.
#### Scenario: Valid name and description are accepted
- **WHEN** the owner enters a name and description within the byte limits
@@ -94,23 +86,31 @@ The wallet SHALL list the family's pending invitations with their expiry state (
### Requirement: Family owner can view the member list grouped by status
The wallet SHALL display all nodes associated with the family grouped into four statuses: **Pending** (active pending invitations), **Joined** (current members), **Rejected** (invitations the node declined), and **Removed** (members that left or were kicked). The list SHALL refresh to reflect current contract state and SHALL render a per-status empty state when a group has no entries. Statuses are derived from the contract queries: pending invitations, current members, and the past-invitation / past-member archives. Large lists SHALL be paginated using the contract's exclusive `start_after` cursor (default page size 50, max 100), fetching subsequent pages via the returned `start_next_after`.
The wallet SHALL display the family's records grouped into four sections: **Pending** (active pending invitations), **Joined** (current members), **Rejected** (invitations the node declined), and **Removed** (members that left or were kicked). Each section is sourced from a distinct contract query and paginates independently using the contract's exclusive `start_after` cursor (default page size 50, max 100), fetching subsequent pages via the returned `start_next_after`. Because the contract stores per-`(family, node)` archive records that accumulate over time, a single node MAY appear in more than one section when its history justifies it (e.g., currently Joined and previously Removed); each row represents a record, not a node. `Revoked` past invitations are owner-side actions and SHALL NOT be shown in the member list. The list SHALL refresh to reflect current contract state and SHALL render an empty state for any section with no entries.
#### Scenario: Large member list is paginated by cursor
- **WHEN** a status group has more entries than one page
- **THEN** the wallet fetches additional pages using `start_after`/`start_next_after` rather than loading the whole list at once
#### Scenario: Large section is paginated by cursor
- **WHEN** a section has more entries than one page
- **THEN** the wallet fetches additional pages using `start_after`/`start_next_after` rather than loading the whole section at once
#### Scenario: Members are grouped by status
#### Scenario: Records are grouped into sections
- **WHEN** the owner opens the member list
- **THEN** nodes appear under Pending, Joined, Rejected, and Removed according to their current contract state
- **THEN** records appear under Pending, Joined, Rejected, and Removed according to which contract query produced them
#### Scenario: Empty status shows an empty state
- **WHEN** a status group has no entries (e.g. no pending invites)
- **THEN** the wallet renders a per-status empty state for that group
#### Scenario: Node appears in multiple sections when history justifies it
- **WHEN** a node is currently a member of the family AND has been kicked or has left at some earlier point
- **THEN** it appears as a row in Joined for the current membership AND as a separate row in Removed for the past kick/leave
#### Scenario: Revoked invitations are not shown in the member list
- **WHEN** a node has only past `Revoked` invitations from this family (no current membership, no pending invite, no past membership, no past Rejected invitation)
- **THEN** the node does not appear in the member list
#### Scenario: Empty section shows an empty state
- **WHEN** a section has no entries (e.g. no pending invites)
- **THEN** the wallet renders an empty state for that section
#### Scenario: List reflects state after an action
- **WHEN** the underlying contract state changes (invite accepted, member kicked, etc.) and the list refreshes
- **THEN** the affected node appears under its new status
- **THEN** the new record appears in its corresponding section, while any pre-existing records for the same node remain in their own sections
### Requirement: Family owner can remove a node from the family
@@ -1,17 +1,16 @@
## 1. Types & request bindings
- [ ] 1.1 Add TS types in `src/types` for `NodeFamily`, `FamilyMembership`, `FamilyInvitation`, `PendingFamilyInvitationDetails`, `PastFamilyInvitation` (with status), `PastFamilyMember`, and contract `Config` (fee + limits)
- [ ] 1.2 Add a standalone `FamilyKey` type (opaque value) and create/accept request args that carry it, isolated behind the request layer
- [ ] 1.3 Add `src/requests/families.ts` Tauri IPC bindings for execute msgs: createFamily, updateFamily (edit, dark/gated), disbandFamily, inviteToFamily, revokeFamilyInvitation, kickFromFamily, acceptFamilyInvitation, rejectFamilyInvitation, leaveFamily
- [ ] 1.4 Add query bindings: getFamilyByOwner, getFamilyMembership, family members paged, pending invitations for family/node paged, past invitations for family paged, past members for family paged, config
- [ ] 1.5 Export new requests from `src/requests/index.ts`
- [ ] 1.2 Add `src/requests/families.ts` Tauri IPC bindings for execute msgs: createFamily, updateFamily, disbandFamily, inviteToFamily, revokeFamilyInvitation, kickFromFamily, acceptFamilyInvitation, rejectFamilyInvitation, leaveFamily
- [ ] 1.3 Add query bindings: getFamilyByOwner, getFamilyMembership, family members paged, pending invitations for family/node paged, past invitations for family paged, past members for family paged, config
- [ ] 1.4 Export new requests from `src/requests/index.ts`
## 2. Context, hooks & query keys
- [ ] 2.1 Create `src/context/families.tsx` (`FamiliesContext` + provider) exposing owner + operator operations, loading/error/refresh; wire into `src/context/index.tsx`
- [ ] 2.2 Add `familyQueryKeys` module mirroring `delegationQueryKeys`
- [ ] 2.3 Add TanStack Query read hooks: useFamilyByOwner, useFamilyConfig, useFamilyMembers, usePendingInvitationsForFamily, usePastInvitationsForFamily, usePastMembersForFamily, usePendingInvitationsForNode, useFamilyMembership
- [ ] 2.4 Add a `useFamilyMemberList` selector hook deriving Pending/Joined/Rejected/Removed from the reads (single source of truth for status mapping)
- [ ] 2.4 Add a `useFamilyMemberList` aggregator hook combining the four section queries (Pending, Joined, Rejected, Removed) into one consumable shape for the UI; each section maps 1:1 to its underlying query (no cross-section deduplication, no priority cascade); Revoked past invitations are not surfaced in any section
- [ ] 2.5 Add execute hooks/methods with optimistic refresh + error surfacing for all nine execute msgs
## 3. node-families-contract mock & fixtures (derived from `openspec/specs/node-families-contract/spec.md`)
@@ -20,7 +19,7 @@
- [ ] 3.2 Seed fixtures: a sample owned family; members across Joined and Removed (left + kicked); past invitations as Rejected and Revoked; pending invitations including at least one expired and one active
- [ ] 3.3 Add a multi-node operator fixture: two controlled nodes with different invite states (active, expired, none)
- [ ] 3.4 Create `src/context/mocks/families.tsx` mirroring the context with `mockSleep` latency and mutable in-memory state (follow the `mocks/bonding.tsx` convention; return `TxResultMock` from execute methods)
- [ ] 3.5 Implement mock execute methods that mutate fixtures and honor contract invariants: createFamily, disbandFamily, inviteToFamily, revokeFamilyInvitation, kickFromFamily, acceptFamilyInvitation, rejectFamilyInvitation, leaveFamily, plus an `onNymNodeUnbond` test helper
- [ ] 3.5 Implement mock execute methods that mutate fixtures and honor contract invariants: createFamily, updateFamily (`updated_name`/`updated_description` `Option<String>`; None = unchanged), disbandFamily, inviteToFamily, revokeFamilyInvitation, kickFromFamily, acceptFamilyInvitation, rejectFamilyInvitation, leaveFamily, plus an `onNymNodeUnbond` test helper
- [ ] 3.6 Implement mock query functions for every contract query: getFamilyById, getFamilyByName (normalised), getFamilyByOwner, getFamilyMembership, and all paginated queries with exclusive `start_after`, default limit 50, max 100, and `start_next_after`
- [ ] 3.7 Enforce contract invariants in the mock: one family per owner, one family per node, monotonic non-recycled ids starting at 1, ASCII name normalisation + global uniqueness, byte-length limits, live `expired = now >= expires_at`, per-`(family, node)` archive counters from 0
- [ ] 3.8 Model the contract error set as typed mock errors (InvalidFamilyCreationFee, FamilyNameAlreadyTaken/TooLong, EmptyFamilyName, SenderAlreadyOwnsAFamily, NodeAlreadyInFamily, NodeDoesntExist, PendingInvitationAlreadyExists, ZeroInvitationValidity, InvitationExpired/NotFound, FamilyNotEmpty, SenderDoesntControlNode, NodeNotMemberOfFamily) so warning/error UI states are reachable
@@ -29,18 +28,17 @@
## 4. Owner UI components (from Figma)
- [ ] 4.1 Pull owner-side designs via Figma MCP and implement: CreateFamily form (name, description, fee display, balance/fee errors), with byte-limit validation + input sanitisation
- [ ] 4.2 EditFamily form (name/description, byte limits, inline over-limit error) — gated behind contract-edit feature flag
- [ ] 4.2 EditFamily form (name/description, byte limits, inline over-limit error): send only changed fields as `Some(value)` and unchanged ones as `None`; if nothing changed, do not submit
- [ ] 4.3 InviteNode form (node ID input, validation) with confirmation and the three warning states (already-in-family, non-existent, duplicate pending)
- [ ] 4.4 PendingInvites list with withdraw (active, confirmation) and clear-expired (confirmation) actions and `expired` badges
- [ ] 4.5 MemberList grouped by Pending/Joined/Rejected/Removed with per-status empty states and refresh
- [ ] 4.6 Kick action with confirmation prompt; DeleteFamily action (empty-only, confirmation, `FamilyNotEmpty` error)
- [ ] 4.7 FamilyKey display shown on successful creation (opaque value)
## 5. Operator UI components (from Figma)
- [ ] 5.1 Pull operator-side designs via Figma MCP and implement: per-node InviteCard (family name, inviting owner, expiry/TTL) with expired = non-actionable
- [ ] 5.2 Multi-node grouping of invites
- [ ] 5.3 Accept action (confirmation, delegate-to-family-key boundary) and Reject action (confirmation)
- [ ] 5.3 Accept action (confirmation) and Reject action (confirmation)
- [ ] 5.4 LeaveFamily action with confirmation
## 6. Family Tab & pages
@@ -71,3 +69,4 @@
- [ ] 9.2 Confirm fee/limits are read from contract config, not hardcoded
- [ ] 9.3 Run `pnpm test`, `build-storybook`, and Playwright; fix failures
- [ ] 9.4 Manual Tauri smoke check of the IPC wiring for at least create + invite + accept (since e2e runs against Storybook, not native)
- [ ] 9.5 On rebase onto the contract change that adds `UpdateFamily`: verify the `ExecuteMsg::UpdateFamily` variant exists, confirm fields are exactly `updated_name: Option<String>` and `updated_description: Option<String>` with None-means-unchanged semantics, confirm sender-must-be-owner auth, and reconcile any drift in `src/requests/families.ts`, the mock execute, and TS types