Bridge Training

Boards

Boards, a deal with teaching metadata: the entity family, codes and enums, the write gate, claiming editor boards, the query object family and filters, comments, analytics, bookmarks and votes, the ports and the errors.

A board is a bridge deal owned by a teacher, with teaching metadata: a short code, a name, a level, an auction system, themes, a sharing mode, a localized review and per-move comments. The game field is a Deal from @workspace/bridge whose comments are LocalizedField<RichText>. The teacher app creates, edits, lists and filters boards; the admin audits and bulk-edits them; the API serves them; the public player reads them through Public; import jobs claim anonymous drafts from Editor boards.

LayerImport path
Domain@workspace/training/domain/boards
Application@workspace/training/application/boards

Every write of deal content goes through one gate (BoardsWriteService), which converges comment shapes, rotates the deal so the declarer sits South, validates it and computes the derived fields stored for filtering. BoardService.create and update enforce two more invariants before calling it: translations must be complete once a deal carries two languages, and the declarer proven by the auction must be South.

Deals and boards

Board

Prop

Type

BoardDeal is Deal<LocalizedField<RichText>, LocalizedField<RichText>, LocalizedField<RichText>>: auction comments, play comments and review are all localized rich text. See Types for the deal shape.

BoardResult is { data: Board; diagnostics: BoardDiagnostic[] }: every read returns the entity with the diagnostics the repository raised while hydrating it (field: "auction" | "play" | "cards" | "analysis", message, path).

BoardMetadata

Prop

Type

auctionThemes and playThemes are system themes, userThemes and userInspirations user themes (see Themes). sharing comes from Sharing.

Permissions, votes, ownership

Prop

Type

Permissions are computed server-side for the viewer passed as viewerUserId, so the front end never reasons about identity. They are null in public contexts.

Prop

Type

Ownership is a discriminated union: { kind: "user"; userId } or { kind: "orphaned"; orphanedAt; reason: OrphanReason }. A board becomes orphaned when its owner hard-deletes it: the row moves under the system account but keeps its id so sessions still resolve it. OrphanReason is "deal_deleted" | (string & {}), open for future triggers. owner is null for orphaned boards: branch on ownership.kind.

FunctionSignatureRule
makeOwnership({ userId, orphanedAt, orphanReason }) => OwnershiporphanedAt !== null means orphaned, reason defaults to "deal_deleted". The single entry point repositories use.
boards.ts
makeOwnership({ userId: "u1", orphanedAt: null, orphanReason: null });
// => { kind: "user", userId: "u1" }
makeOwnership({ userId: "u1", orphanedAt: "2026-01-01T00:00:00Z", orphanReason: null });
// => { kind: "orphaned", orphanedAt: "2026-01-01T00:00:00Z", reason: "deal_deleted" }

BoardSummary family

BoardSummary is the minimal representation from the unified deals view, enough to preview and play a deal. TinyBoardSummary is an alias: the base shape, the only thing editor-source rows can offer. RichBoardSummary is the app-only superset with metadata, themes, owner and votes, enough to render a rich preview without a second read. SearchByCodeResult discriminates them: { kind: "tiny"; board: TinyBoardSummary } or { kind: "rich"; board: RichBoardSummary }.

Prop

Type

An editor-source review with no recorded language is keyed under the BCP-47 sentinel "und"; consumers must ask for a real language before persisting it.

Prop

Type

Collection entries

CollectionBoardEntry replaces parallel boardIds and unavailableBoardIds arrays in collections and sessions: { status: "available"; board: BoardResult } or { status: "unavailable"; id } when the deal was unshared or made private by its owner.

FunctionSignatureRule
getBoardEntryId(entry: CollectionBoardEntry) => stringThe board id whatever the status
getEntryBoardIds(entries: CollectionBoardEntry[]) => string[]Ordered id list
projectBoardEntries (application)(orderedBoardIds: readonly string[], readable: readonly BoardResult[]) => CollectionBoardEntry[]Keeps the junction order; an id absent from readable becomes an unavailable tombstone
boards.ts
export function entriesInOrder(
  orderedIds: string[],
  readable: BoardResult[]
): string[] {
  const entries = projectBoardEntries(orderedIds, readable);
  // entries[i] => { status: "available", board } or { status: "unavailable", id }
  return getEntryBoardIds(entries); // same order as orderedIds
}

Languages

A board's languages are the union of locale keys holding visible content across the review, every auction comment and every play comment. An empty result means the deal is language-agnostic; the repository treats it as matching every language filter. DealLanguageInput is the minimal structural shape (review?, auction?, play?), satisfied by any BoardDeal or partial snapshot.

FunctionSignatureRule
filledLocales(field: LocalizedField<RichText> | undefined) => string[]Locales whose rich text is non-empty after trimming
getDealContentLanguages(deal: DealLanguageInput) => string[]Sorted, deduplicated union
getDealLanguageGaps(deal: DealLanguageInput) => DealLanguageGapsReport{ languages, gaps }; a slot only produces a gap when it has at least one filled locale and misses one of the board languages. DealLanguageGap is { category: "review" | "auction" | "play"; index?; missing }
boards.ts
export function languagesOf(game: BoardDeal) {
  getDealContentLanguages(game); // => ["en", "fr"] when review or comments hold both
  const report = getDealLanguageGaps(game);
  report.languages; // => ["en", "fr"]
  report.gaps; // => [{ category: "auction", index: 2, missing: ["fr"] }]
  // a slot without any filled locale is never a gap; [] languages = language-agnostic
  return report;
}

Sequence adapters

Converters between the flat sequence stored in the database (Sequence<TComment> = { value: string; comment?: TComment }[]) and the bridge Auction / Play. Generic over the comment type, no casts needed.

FunctionSignature
sequenceToDealAuction<T>(sequence: Sequence<T>, dealer: Player) => Auction<T>
sequenceToDealPlay<T>(sequence: Sequence<T>, leader?: Player) => Play<T>, leader defaults to "N"
dealAuctionToSequence<T>(auction: Auction<T>) => Sequence<T>
dealPlayToSequence<T>(play: Play<T>) => Sequence<T>
boards.ts
export function roundTrip(game: BoardDeal) {
  const flat = dealAuctionToSequence(game.auction); // [{ value: "1S", comment: undefined }, …]
  const back = sequenceToDealAuction(flat, game.auction.first);
  return back; // { first: "S", moves: [{ bid: "1S" }, …] }
}

PBN export

boardsToPBN(opts: BoardsToPbnOptions): string serialises boards into one multi-deal PBN file. Boards are numbered from startBoardNumber and repeated seriesCount times; each deal is rotated so its dealer matches the standard dealer of its box number, using the injected rotater (apps hand in their localized one from @workspace/bridge/rotation). Comments are flattened to plain text in language.

Prop

Type

boards.ts
export function exportCollection(
  boards: Board[],
  rotater: Rotater, // the app's localized rotater
  language: string
): string {
  return boardsToPBN({
    boards,
    startBoardNumber: 1,
    seriesCount: 2, // boards repeated twice, board numbers continue
    language,
    rotater,
    event: "Tuesday class",
    site: "https://example.test",
    now: new Date(),
  });
}

AuctionSystem and Bookmark

Prop

Type

Prop

Type

Codes and enums

Deal codes

A deal code is the 8-character public identity of a board, used in player URLs and sharing. Generation delegates to the short-code utility of @workspace/shared (56-character alphabet without ambiguous glyphs). Uniqueness is enforced by the repository.

ExportDefinition
DEAL_CODE_LENGTH8
DEAL_CODE_SEARCH_MIN_CHARS8: an exact search only fires once the full code is typed
generateDealCode()Random code from the alphabet; two identical deals get different codes
isValidDealCode(code)Strict check for new codes: exact length, alphabet only
isLegacyCompatibleDealCode(code)Lenient check for boundaries that reference existing codes: ^[A-Za-z0-9]{8}$, because legacy codes predate the alphabet
boards.ts
const code = generateDealCode(); // 8 chars from the short-code alphabet
isValidDealCode(code); // => true
isValidDealCode("abcdefgh"); // => false when a char is outside the alphabet
isLegacyCompatibleDealCode("abcdefgh"); // => true: length and alphanumeric shape only
isLegacyCompatibleDealCode("abc"); // => false

Levels, scoring, vulnerability, contract level, counter status

ExportDefinition
LEVEL{ BEGINNER: 1, INTERMEDIATE: 2, ADVANCED: 3, EXPERT: 4, UNSET: "UNSET" }
LEVEL_VALUES[1, 2, 3, 4]
ClassifiedLevel1 | 2 | 3 | 4, a level explicitly set
LevelClassifiedLevel | "UNSET", the read-side type
parseLevel(value)Anything outside 1 to 4 becomes "UNSET"
isUnsetLevel(level)Type guard for "UNSET"
SCORING_MODE / SCORING_MODE_VALUES{ TPP: "TPP", IMP: "IMP" }, filter-safe (no UNSET)
parseScoring(raw: unknown)ScoringMode, "UNSET" for anything invalid
VULNERABILITY / VULNERABILITY_VALUES{ NONE, NS, EW, ALL }, filter-safe
parseVulnerability(raw: unknown)Vulnerability, "UNSET" for anything invalid
CONTRACT_LEVEL / CONTRACT_LEVEL_VALUES{ PARTIAL: "partial", GAME: "game", SMALL_SLAM: "small_slam", GRAND_SLAM: "grand_slam" }
parseCounterStatus(raw: unknown)CounterStatus, "none" for anything invalid

ContractLevel and CounterStatus are re-exported from @workspace/bridge/types.

boards.ts
parseLevel(3); // => 3
parseLevel(7); // => "UNSET"
isUnsetLevel(parseLevel(null)); // => true
parseScoring("IMP"); // => "IMP"
parseScoring("bogus"); // => "UNSET"
parseVulnerability("NS"); // => "NS"
parseVulnerability(undefined); // => "UNSET"
parseCounterStatus("XX"); // => "XX"
parseCounterStatus(42); // => "none"

Votes

ExportDefinition
VOTE_TYPES{ UP: "up", DOWN: "down" }, VoteType is the union
VoteDeltas{ score, upvotes, downvotes }
computeVoteDeltas(currentVote, newVoteType)Optimistic deltas: toggle off when the vote repeats, new vote when none, switch when it flips (score moves by 2)
invertVoteDeltas(deltas)Negates every field, for rollback
boards.ts
computeVoteDeltas(null, "up"); // => { score: 1, upvotes: 1, downvotes: 0 }
computeVoteDeltas("up", "up"); // => { score: -1, upvotes: -1, downvotes: 0 } (toggle off)
computeVoteDeltas("down", "up"); // => { score: 2, upvotes: 1, downvotes: -1 } (switch)

Writing boards

BoardService

createBoardService(deps: BoardServiceDeps): BoardService, where the deps are { boards: BoardRepository; boardsRead: BoardReadRepository; themesRead: ThemeReadRepository; boardsWrite: BoardsWriteService; boardThemes: BoardThemesRepository; uow: BoardWriteUnitOfWork }.

create and update write through uow.run: the new themes and inspirations, the board and its theme links commit or roll back together. BoardWriteTx is { boards; boardsWrite; themes; boardThemes }, bound to the transaction. Everything the write needs is read before the transaction opens, so a request never holds two connections. The database package's createDrizzleBoardWriteUnitOfWork(db: OwnerConnection) implements it.

Every method takes ctx: ServiceContext last (see Service Context). The service holds the access rules, whatever connection the repositories run on:

  • Reads are scoped with visibleTo(ctx.userId): the caller's own boards, public ones, shared ones whose owner has the caller in a sharing group, and orphans still linked from one of the caller's sessions or collections. A board carries system themes and the caller's own themes, never another account's. Its score counts every account's vote. There is no admin view: an administrator reads like any other account.
  • Mutations (update, softDelete, restore, hardDelete, linkThemes) first look the boards up among the caller's own: any id not found there, missing or another account's, throws AccessDeniedError before any write, so the answer does not reveal whether the board exists.
  • Theme ids given to create, update or linkThemes must be system themes or the caller's own; any other id, a missing one included, throws AccessDeniedError.
MethodSignatureDescription
create(input: CreateBoardInput, ctx) => Promise<BoardResult>Generates a code, asserts translation completeness and declarer South, runs the gate as a dry run first so no theme is created when it rejects, creates newThemes and newInspirations, writes through boardsWrite.writeOne, links themeIds plus the new ones, re-reads the entity. Throws ValidationError with BOARD_TRANSLATION_INCOMPLETE, BOARD_DECLARER_NOT_SOUTH or BOARD_GATE_REJECTED; a code conflict or a persistence failure throws a plain Error.
update(input: UpdateBoardInput, ctx) => Promise<BoardResult>Creates new themes first. With game, asserts the same invariants and overwrites in place through boardsWrite.overwriteMany (the stored code is kept). Without game, patches the index columns from metadata (name, boardNumber, sharing, level, auctionSystemId). themeIds are replaced only when themeIds, newThemes or newInspirations were supplied.
softDelete(boardId, ctx) => Promise<void>Ownership check, then sets deletedAt under BoardCriteria.byId(id).notInTrash(); an already trashed row is an idempotent no-op.
restore(boardId, ctx) => Promise<void>Ownership check, then clears deletedAt.
hardDelete(boardId, ctx) => Promise<void>Ownership check, then requires the board to be in the trash, else throws BoardNotInTrashError. Then deletes with { mode: "orphan" }: the board moves to the orphan account, keeping its id, and leaves its former owner's collections and sessions; other owners' sessions and collections still play it.
get(boardId, ctx) => Promise<BoardResult>getById with the entity() projection, the caller as viewer and visibleTo. Throws NotFoundError for a missing board and for one the caller may not read.
getByIds(boardIds: string[], ctx) => Promise<BoardResult[]>Lifecycle "all"; result follows the input order and skips unknown or invisible ids.
getFilteredPaginated(params: BoardListInput, ctx) => Promise<FilteredBoardsWithPagination>The deals page: translates the wire filters into a BoardQuery, scoped to the visible boards.
searchByCode(params: { code }, ctx) => Promise<BoardResult[]>Exact code among the visible boards, lifecycle includingOrphans so old session links resolve.
getDistinctLanguages(ctx) => Promise<string[]>BoardAggregations.distinctLanguages() over the visible boards.
linkThemes(links: BoardThemeLink[], ctx) => Promise<{ linked: number }>Links the caller's boards to system themes or to its own themes through BoardThemesRepository.writeMany.
boards.ts

export function createBoard(
  deps: BoardServiceDeps,
  input: CreateBoardInput,
  ctx: ServiceContext
): Promise<BoardResult> {
  const boards = createBoardService(deps);
  // Throws ValidationError(BOARD_TRANSLATION_INCOMPLETE | BOARD_DECLARER_NOT_SOUTH | BOARD_GATE_REJECTED)
  return boards.create(input, ctx);
}

export function newBoardInput(game: BoardDeal): CreateBoardInput {
  return {
    game, // declarer South, translations complete
    metadata: {
      name: "Stayman after 1NT",
      auctionSystem: "sef",
      sharing: "private",
      level: LEVEL.INTERMEDIATE,
    },
    newThemes: [{ title: "Stayman" }], // created before the board, then linked
  };
}
boards.ts
export async function trashThenPurge(
  deps: BoardServiceDeps,
  boardId: string,
  ctx: ServiceContext
): Promise<void> {
  const boards = createBoardService(deps);
  await boards.softDelete(boardId, ctx); // idempotent; AccessDeniedError unless ctx.userId owns it, missing included
  await boards.restore(boardId, ctx); // back in the explorer
  await boards.softDelete(boardId, ctx);
  await boards.hardDelete(boardId, ctx); // BoardNotInTrashError without the softDelete above
}

DTOs

CreateBoardInput is Omit<CreateBoardDTO, "code" | ComputedFields>: the caller never supplies the code nor the derived fields (partnership, contractLevel, counterStatus, opener, flattenedAuction, cardCounts, supportedLanguages). UpdateBoardInput is Omit<UpdateBoardDTO, ComputedFields>.

Prop

Type

Prop

Type

Prop

Type

NewThemeDTO is { title: string }: a user theme or inspiration created inline, stored under the "default" label key.

BoardsWriteService

The write gate. createBoardsWriteService(deps: { boards: BoardRepository; boardsRead: BoardReadRepository; clock?: () => Date }). Every method takes opts: WriteOpts = { commentLocale: string | null }: the BCP-47 locale that plain-string and non-localized RichText comments are wrapped at. With null, any such comment rejects the write.

The gate accepts a RawBoardData (an EditorBoardData whose move comments may be string, RichText or LocalizedField<RichText>) and, in order:

  1. Rejects a code that is not a valid deal code, unless it equals the grandfatheredCode byte for byte (invalid_code). Overwrites pass the stored code as grandfathered, so a legacy code stays writable as long as it is kept unchanged; a changed code is code_mismatch.
  2. Canonicalises the deal shape for the teaching mode's auction input, resolves the declarer (a complete auction overrides the declared one and report.declarerCorrected records it), and derives the rotation count to South.
  3. Rejects when unlocalized comments are present and commentLocale is null (comments_present_without_locale).
  4. Converts comments: strings are rotated (player names) and parsed from PBN text, RichText is wrapped at the locale, localized fields pass through. report.commentsConverted counts them.
  5. Rotates the deal structurally so the declarer sits South. When no final comment was supplied, the rotated review becomes the final comment at the locale.
  6. Validates: missing_declarer, missing_player, cards_incomplete (malformed_hands or invalid_total_<n>: 52 cards, or an equal multiple of 4 per seat), auction_first_invalid, and play_order_invalid from the engine's chronological trick replay (card_not_owned, revoke, invalid_play). No repair.
  7. Computes ValidatedDerivedFields (denomination, contractLevel, opener, partnership, counterStatus, flattenedAuction, cardCounts, supportedLanguages) and stamps the persistable write with gatedAt (from clock) and gatedVersion.
MethodSignatureDescription
writeOne(input: BoardsWriteInput, opts) => Promise<BoardsWriteOutcome>Gate, then boards.writeOne. A repository throw becomes persistence_failed with the message.
writeMany(inputs: BoardsWriteInput[], opts) => Promise<BoardsWriteOutcome[]>One outcome per input in order; only gated inputs reach boards.writeMany; board ids and failures are mapped back by code.
overwriteMany(items: { boardId; input }[], opts) => Promise<BoardsWriteOutcome[]>Loads the stored codes, rejects a changed code with code_mismatch, gates with the stored code grandfathered, then boards.overwriteMany. persisted outcomes carry the existing boardId.
checkMany(inputs, opts) => GateCheckOutcome[]Synchronous gate only, no write: { code, ok: true } or { code, ok: false, rejections }. Used by dry runs.
gateToPersistable(input, opts) => GatePersistOutcomeGate only, returning { ok: true, persistable } or { ok: false, rejections }.
diffMany(items: { boardId; input }[], opts) => Promise<BoardDiffOutcome[]>Gates and compares against the stored Board in domain form: identical, differs or gate_rejected. The comparator is conservative: any uncertainty resolves to differs.
BoardsWriteOutcome.statusFields
persistedboardId, report: BoardsWriteReport
gate_rejectedrejections: GateRejection[]
already_existsnone
persistence_faileddetail?

Prop

Type

Prop

Type

GateRejection is { reason: GateRejectionReason; detail?: string } with GateRejectionReason one of missing_declarer, missing_player, cards_incomplete, play_order_invalid, comments_present_without_locale, auction_first_invalid, invalid_code, code_mismatch. RawBoardData, RawAuctionMove, RawPlayMove and ValidatedDerivedFields are exported as types; the gate function itself is internal to the package.

boards.ts
export async function importOne(
  boards: BoardRepository,
  boardsRead: BoardReadRepository,
  input: BoardsWriteInput // raw deal + overrides, code generated by the caller
) {
  const writer = createBoardsWriteService({ boards, boardsRead });

  const check = writer.checkMany([input], { commentLocale: "fr" });
  if (!check[0]?.ok) {
    return check[0]?.rejections; // [{ reason: "cards_incomplete", detail: "invalid_total_48" }]
  }

  const outcome = await writer.writeOne(input, { commentLocale: "fr" });
  switch (outcome.status) {
    case "persisted":
      return outcome.report; // { rotated, rotationCount, commentsConverted, declarerCorrected? }
    case "gate_rejected":
      return outcome.rejections;
    case "already_exists":
    case "persistence_failed":
      return outcome.status;
  }
}

Derived fields

computeClaimDerivedFields(source: ClaimDerivableSource): ClaimDerivedFields computes cardCounts, partnership, opener, contractLevel, counterStatus and flattenedAuction from cards, contract, player and auction. It throws ValidationError with VALIDATION_ERROR when player is missing. The gate computes its own derived fields; this helper serves callers that need them without a write.

Claiming

Claiming turns an anonymous editor board into an owned board. The editor record's data is the gate's raw input, the editor code is grandfathered, and commentLocale gives the editor's plain comments their language.

BoardClaimService

createBoardClaimService(deps: BoardClaimServiceDeps) with { editorBoardsRead, boardsRead, boardsWrite }.

MethodSignatureDescription
plan(input: ClaimInput) => Promise<ClaimPlan>Read-only. editor_missing, already_exists (with boardId) or will_create, the last two carrying the editor source and the overrides. Never writes.
apply(input: ClaimInput) => Promise<ClaimResult>not_found, already_exists or created with the new boardId. A gate rejection or a persistence failure throws a plain Error naming the code and the reasons.
parseOverrides(raw: string) => ClaimOverridesParses a JSON object, keeps string values under name, boardNumber, scoring, sharing, complement, drops anything else. Throws on invalid JSON or a non-object. Also exported as a standalone function.

Prop

Type

Prop

Type

boards.ts
export async function claimEditorBoard(
  deps: BoardClaimServiceDeps,
  code: string,
  userId: string
) {
  const claim = createBoardClaimService(deps);
  const plan = await claim.plan({ code, userId });
  if (plan.status === "already_exists") {
    return plan.boardId;
  }
  const result = await claim.apply({
    code,
    userId,
    overrides: claim.parseOverrides('{"name":"Lesson 3","sharing":"shared"}'),
    commentLocale: "fr", // locale the editor's plain comments are wrapped at
  });
  return result.status === "created" ? result.boardId : null;
}

BoardBulkClaimService

createBoardBulkClaimService(deps: BoardBulkClaimServiceDeps). It takes editorBoardsRead, boardsRead and boardsWrite like the claim, boardThemes (writeMany, for the theme links), accounts (AccountsService.resolveIdsByEmails, to resolve owner and share emails), themes and themesRead (user themes by slug), sharingGroups (the SharingGroupsService, for ensureWithMembers) and getThemeLocale(). Two callers: the admin xlsx and csv claim, and pnpm cli --actor <accountId> import-xlsx-metadata, whose dry run is plan and whose --apply is apply, both under the operator's context.

MethodSignatureDescription
plan(rows: BulkClaimRow[], opts: { commentLocale? }, ctx: ServiceContext) => Promise<BulkClaimPlan>Resolves every row to a BulkClaimRowStatus, runs the gate on writable rows (will_insert, will_replace) so the dry run mirrors the write, and diffs already-claimed rows: one whose content would change is promoted to will_update, an identical one stays skip_already_claimed.
apply(plan: BulkClaimPlan, opts: BulkClaimApplyOptions, ctx: ServiceContext) => Promise<BulkClaimStats>Re-resolves from current database state (the plan is a hint), creates one "Imported sharing" group per owner with the resolved share members, creates missing user themes by slug (locale from getThemeLocale), writes inserts in batches of batchSize (default 100) through boardsWrite.writeMany, links themes, overwrites will_replace rows through overwriteMany, and reports every gate rejection and persistence failure in the stats. onProgress(processed) fires after each batch.
BulkClaimRowStatusMeaning
will_insertNew claim
will_updateAlready claimed, content differs (plan only; apply does not overwrite it unless the row sets replace)
will_replaceAlready claimed and the row opted into replace
skip_editor_missingNo editor board for the code
skip_owner_missingownerEmail unknown
skip_duplicateCode repeated in the input
skip_already_claimedAlready claimed and identical
skip_gate_rejectedContent rejected by the gate, gateRejections set

Prop

Type

Prop

Type

Row helpers are exported for the import tooling.

ExportDefinition
IMPORTED_SHARING_GROUP_NAME"Imported sharing"
difficultyToLevel(value)easy 1, medium 2, hard 3, very hard 4, case-insensitive; null otherwise
slugifyThemeLabel(label)Lower-case, diacritics stripped, non-alphanumerics collapsed to -
buildBulkClaimWriteInput(row, ownerId, source)The BoardsWriteInput of a row: sharing from row.sharing, else "shared" when shareEmails is non-empty, else "private"; code grandfathered
boards.ts
export async function bulkClaim(
  deps: BoardBulkClaimServiceDeps,
  rows: BulkClaimRow[],
  ctx: ServiceContext // the acting admin
) {
  const bulk = createBoardBulkClaimService(deps);
  const plan = await bulk.plan(rows, { commentLocale: "fr" }, ctx);
  plan.rows.map((r) => r.status); // "will_insert" | "will_update" | "skip_already_claimed" | …
  plan.stats.gateRejections; // [{ code, reasons }]
  const stats = await bulk.apply(
    plan,
    {
      commentLocale: "fr",
      batchSize: 100,
      onProgress: (n) => console.log(`${n} rows processed`),
    },
    ctx
  );
  return stats.inserted;
}

PbnImportService

createPbnImportService(deps: PbnImportServiceDeps) turns spreadsheet rows and already-parsed PBN files into owned boards. It composes services only: accounts (resolveIdsByEmails), themes (checkSystemThemeIds, ensureOwnerThemes), sharingGroups (ensureWithMembers), dealCollections (listByOwner, ensureForOwner) and boardsWrite (writeMany), plus the boardThemes junction (writeMany): the importer links boards it writes for many owners to their own themes, which the caller-scoped BoardService.linkThemes would refuse. PBN parsing and the deal-to-source mapping stay with the caller: each PbnImportFile carries a parsed first deal, an empty file or a parse error.

MethodSignatureBehaviour
plan(input: PbnImportInput, ctx) => Promise<PbnImportPlan>Dry run, never writes. Matches each row to its owner and its file (refs compared case-insensitively, .pbn implied), gives every creatable row a fresh deal code and its BoardsWriteInput, counts the statuses, lists unreferenced files and unknown owner emails, reports invalid system-theme ids and the theme or inspiration labels too long for a theme title, and plans one collection per owner and name (create or add_to_existing).
apply(input: PbnImportInput, ctx) => Promise<PbnImportReport>Resolves the rows afresh, then throws before any write: InvalidThemeTitlesError if a theme or inspiration label is too long for a theme title, UnknownSystemThemesError if a system-theme id is unknown or of the wrong kind. Otherwise writes, in order: the owners' user themes by slug, one "Imported sharing" group per owner with the resolved share members, the boards, the theme links (user and system), and the collections, which reuse an existing one of the same name and only add missing boards.
PbnImportRowStatusMeaning
will_createOwner and file resolved, first deal parsed
skip_duplicate_pbn_refAn earlier row already uses this file
skip_owner_missingThe owner email names no account
skip_pbn_missingNo uploaded file matches the ref
skip_pbn_parse_errorThe file did not parse; parseError carries the message
skip_no_dealsThe file parsed but holds no deal

Prop

Type

Prop

Type

normalizePbnRef(ref) is the file key both sides use: trimmed, lower-cased, .pbn appended when missing.

boards.ts
export async function importPbn(
  deps: PbnImportServiceDeps,
  rows: PbnImportRow[],
  files: PbnImportFile[],
  ctx: ServiceContext
) {
  const importer = createPbnImportService(deps);
  const plan = await importer.plan({ rows, files, language: "fr" }, ctx);
  plan.stats.willCreate;
  plan.collections; // [{ name, ownerEmail, status: "create" | "add_to_existing", … }]
  if (plan.invalidThemeTitles.length > 0) {
    return null; // apply would throw InvalidThemeTitlesError
  }
  if (plan.invalidSystemThemeIds.length > 0) {
    return null; // apply would throw UnknownSystemThemesError
  }
  const report = await importer.apply({ rows, files, language: "fr" }, ctx);
  return report.inserted;
}

Queries and filters

The generics (QueryObject, Criteria, Projection, Aggregation, ReadRepository, WriteRepository, pagination) are described on Queries. This section lists the board bindings.

BoardQuery

Immutable query object for reads. BoardQueryParams extends QueryParams<BoardWhere, BoardSort> with two decorations: viewerUserId (permissions and avatars, never a filter) and lifecycleMode. Visibility is a filter of its own, where.visibleTo.

LifecycleModeRows
active (default)Not trashed and not orphaned: UI lists
includingOrphansNot trashed; orphans stay reachable so historical session links resolve
onlyTrashedTrash view
allNo constraint: id-batch lookups (sessions, collections, claim), admin
MethodWhere produced
BoardQuery.empty()Every board the viewer can see
.ownedBy(userId) / .notOwnedBy(userId)userId eq / ne
.visibleTo(accountId)visibleTo: the boards the account may read, and only its own user themes on them
.bookmarkedBy(userId)Join on bookmarks
.withSharing(sharing)sharing eq
.exposedInCatalog()public and the active lifecycle: the deals the author catalog exposes
.withId(id) / .withIds(ids)id eq / in
.withCode(code) / .withCodes(codes)code eq / in
.byAuthors(authorIds)authorIds in
.withLevels(levels)level in
.withLanguages(languages)supportedLanguages.overlapsOrEmpty: language-agnostic boards match
.withScoring(modes) / .withVulnerabilities(values) / .withPlayers(players) / .withContractLevels(levels) / .withCounters(statuses) / .withPedagogy(modes)Membership on the matching column
.withCompetitive(values)isCompetitive eq; both values or none is a no-op
.withSides(sides)partnership.inFromSides, mapped by the repository
.withTrumpSuit({ suits?, includeNT?, includeTrump? })denomination.matchTrump
.withThemeGroups(groups)themeIds.existsAnyOf: one EXISTS per group, OR inside a group, AND across groups
.withBiddingSequence(sequence)biddingSequence.matches prefix match
.withCommentLocales(locales) / .withCommentMatchesAny(patterns)comment.hasAnyLocale / comment.matchesAny; empty lists are no-ops; patterns must be escaped by the caller
.withLifecycle(mode) / .onlyTrashed() / .includingOrphans()Lifecycle decoration
.withPagination(p) / .withSorting(s) / .asViewer(userId)Read concerns
.withFilters(where)Raw BoardWhere merge
.overrideWith(other)Merge another query, second wins on conflicts
.toParams()The frozen BoardQueryParams
boards.ts
export function recentSharedBoards(
  boardsRead: BoardReadRepository,
  viewerUserId: string
): Promise<BoardResult[]> {
  const query = BoardQuery.empty()
    .notOwnedBy(viewerUserId)
    .withSharing("public")
    .withLevels([1, 2])
    .withLanguages(["fr"]) // language-agnostic boards match too
    .withTrumpSuit({ suits: ["H", "S"], includeNT: false })
    .withSorting({ column: "createdAt", desc: true })
    .withPagination({ page: 1, pageSize: 20 })
    .asViewer(viewerUserId); // permissions and avatars, never a filter
  return boardsRead.findMany(query, BoardProjections.entity());
}

export async function codesOf(
  boardsRead: BoardReadRepository,
  ids: string[]
): Promise<string[]> {
  const refs = await boardsRead.findMany(
    BoardQuery.empty().withIds(ids).withLifecycle("all"),
    BoardProjections.ids()
  );
  return refs.map((r) => r.code);
}

BoardWhere

The leaf filter shape returned by toParams().where and consumed by the repository. Fields are AND-combined; names mirror domain concepts, not columns. mergeWhere(a, b) shallow-merges two fragments.

Prop

Type

BoardCriteria

Filter-only query object for update, delete and scoped aggregations. Inherits the empty-where refusal from Criteria.

Static / methodWhere produced
BoardCriteria.byId(id) / .byIds(ids)id eq / in
BoardCriteria.byCode(code) / .byCodes(codes)code eq / in
BoardCriteria.where(where)Any BoardWhere
.and(where)AND an extra fragment
.notInTrash() / .inTrash()deletedAt is null / not null

BoardUpdatePatch

The payload of BoardRepository.update. index patches the index columns, payload the JSONB payload columns, themeIds replaces the theme junction ([] clears it, undefined leaves it), recomputeAuctionSearch asks the repository to recompute the auction search tokens from { auction, dealer }.

Prop

Type

Prop

Type

boards.ts
export async function renameBoards(
  boards: BoardRepository,
  ids: string[],
  name: string
): Promise<number> {
  const { updatedIds } = await boards.update(
    BoardCriteria.byIds(ids).notInTrash(),
    { index: { name } }
  );
  return updatedIds.length;
}

export function clearThemes(boards: BoardRepository, boardId: string) {
  return boards.update(BoardCriteria.byId(boardId), { themeIds: [] });
}

BoardSort

{ column: BoardSortColumn; desc: boolean } with BOARD_SORT_COLUMNS = ["level", "name", "boardNumber", "createdAt", "updatedAt", "deletedAt", "publishedAt"] as the trust boundary for sort input. publishedAt is the first time the board became public (boards_index.published_at, written by the repository on every path that sets sharing, never cleared); its nulls sort last in both directions. votes is absent on purpose: vote counts live in another table.

BoardProjections

FactoryResult typeContents
entity()BoardResultFull hydration: index, payload, themes, owner, votes, diagnostics
ids()BoardIdsRow{ id, code } for existence checks and references
finalComment()BoardFinalCommentRow{ boardId, comment } for the admin comment search

BoardAggregations

Each factory returns an Aggregation<T>; criteria scopes the rows, default scope excludes trashed and orphan rows.

FactoryResult type
distinctLanguages(criteria?)string[]
count(criteria?)number
groupBy(field: EnumAuditField, criteria?)GroupCount[], field one of scoring, sharing, dealer, declarer, vuln, level, contractStrain
topValues(field: FreeFormAuditField, limit, criteria?)TopValuesResult = { items, totalDistinct, otherCount }, field boardNumber or name
dateHistogram(field: DateAuditField, "month", criteria?)DateHistogramBucket[] = { period, count }[], field createdAt or updatedAt
themeUsage(limit) / sharingGroupUsage(limit)UsageResult = { items, totalDistinct }
commentsStats(criteria?)CommentsStats

Prop

Type

Legacy filters

BoardFilters and BoardFiltersQuery are the filter value objects of the advanced search: a query is a list of groups combined with OR, each group AND-combining its fields. BiddingSequenceFilter is Bid[][], one array of alternatives per position. BoardListFilters / BoardListInput are the wire shape of the deals page, translated into a BoardQuery by the service. BoardSearchByCodeInput is { code }; FilteredBoardsWithPagination is the list response.

Prop

Type

Prop

Type

Limits protect query plans and keep the UI readable. BOARD_FILTERS_LIMITS aggregates them and BoardFiltersLimits is its type.

ConstantValueMeaning
MAX_FILTER_GROUPS3OR groups per query
MAX_PAGE_SIZE100Results per request
MAX_OFFSET1000Deepest offset served
MAX_SYSTEM_THEMES10System themes per group
MAX_USER_CUSTOM_THEMES5User themes per group
MAX_SEQUENCES_PER_GROUP20Bidding sequences per group after padding
MAX_SEQUENCE_LENGTH12Bids per sequence including padding
MAX_FILTER_SEQUENCE_BIDS8Bids a user may enter
MAX_PASS_PADDING3Leading Pass variants
FunctionSignatureRule
cleanFiltersQuery(query: BoardFiltersQuery) => BoardFiltersQueryKeeps the first MAX_FILTER_GROUPS groups, cleans each (drops empty arrays and unknown values, truncates to the limits, keeps at most 8 criteria per group), removes empty groups, expands bidding sequences into their padded variants
generatePaddedSequences(sequence: BiddingSequenceFilter) => BiddingSequenceFilter[]A sequence starting with Pass is returned alone; otherwise variants with 0 to 3 leading Pass bids, within MAX_SEQUENCE_LENGTH
isEmptyQuery(query: BoardFiltersQuery) => booleanNo group or only empty groups
boards.ts
const query: BoardFiltersQuery = [
  { levels: [1, 2], biddingSequence: [["1NT"], ["Pass"], ["2C"]] },
  {}, // empty group, dropped
];
cleanFiltersQuery(query);
// => 4 groups: the sequence is expanded with 0 to 3 leading Pass bids

generatePaddedSequences([["Pass"], ["1S"]]); // => [[["Pass"], ["1S"]]]: already starts with Pass

assertBoardsAccessible

assertBoardsAccessible(boardReadRepository: BoardReadRepository | undefined, boardIds: string[], accountId: string): Promise<void> checks that every id resolves to a board the account may read (visibleTo(accountId), lifecycle all, ids() projection) before a session or collection mutation and throws InvalidBoardReferenceError with the missing ids. It is skipped when no repository is injected or the list is empty.

Comments

BoardsCommentsService

Admin bulk surface for the final per-locale board comment. createBoardsCommentsService(deps: { boards: BoardRepository; boardsRead: BoardReadRepository }).

MethodSignatureDescription
listByLocaleAndPattern(opts: ListByLocaleAndPatternOptions) => Promise<BoardFinalCommentRow[]>{ boardId, comment } rows under an optional locales and matchesAny prefilter, optional ownerId, limit default 5000, createdAt ascending, lifecycle includingOrphans. CommentsRewriteService runs the per-locale rich-text matching afterwards.
updateComments(rows: { boardId; comment: LocalizedField<RichText> }[]) => Promise<{ updated: number }>One updateMany call, one transaction; rows whose id matches nothing contribute no count. Empty input returns { updated: 0 } without a call.

Comment rewrite rules

domain/boards holds the pure rules of the bulk search and replace over that comment. A CommentRule is { find, locale, action, replace? }:

  • find is the literal * (every comment), a regex when it holds a regex metacharacter (isRegexFind), else a literal matched anywhere in the plain text.
  • locale is * (every locale of the comment) or a supported locale code.
  • action is one of COMMENT_RULE_ACTION_VALUES: search only reports the match, replace substitutes replace for every occurrence, delete_block removes every occurrence, append adds replace (markdown, with \n and \t escapes) as new blocks, delete_comment drops the locale.

Rules run in order on each text leaf of the matched locale. A locale is will_update when its plain text changes or it is dropped, no_change when the rewrite keeps the text, matched for search-only rules; a row takes the strongest status of its locales. A literal rule narrows the repository scan with an escaped ilike pattern and its locale; any regex, wildcard find or wildcard locale lifts that part of the prefilter.

CommentsRewriteService

The plan/apply use case over BoardsCommentsService. createCommentsRewriteService(deps: { boardsComments: BoardsCommentsService; accounts: Pick<AccountsService, "resolveIdsByEmails"> }).

MethodSignatureDescription
plan(input: CommentsRewriteInput, ctx) => Promise<CommentsRewritePlan>Validates and compiles the rules, resolves the optional owner email (trimmed, lower-cased), scans at most COMMENT_REWRITE_SCAN_LIMIT rows and returns the matched rows, the rewritten comments (pending) and the stats; truncated once the scan hits the limit. An unknown owner returns ownerResolved: false and empty stats without scanning.
apply(plan: CommentsRewritePlan, ctx) => Promise<CommentsRewriteReport>Writes plan.pending through updateComments in one batch and returns the stats with updated.
boards.ts
export async function replaceInComments(
  boards: BoardRepository,
  boardsRead: BoardReadRepository,
  accounts: AccountsService,
  ctx: ServiceContext
) {
  const rewrite = createCommentsRewriteService({
    boardsComments: createBoardsCommentsService({ boards, boardsRead }),
    accounts,
  });
  const plan = await rewrite.plan(
    {
      ownerEmail: "teacher@example.com",
      rules: [
        { find: "Stayman", locale: "fr", action: "replace", replace: "2♣" },
      ],
    },
    ctx
  );
  // plan.rows => [{ boardId, rowStatus, locales }], plan.stats => { scanned, matched, willUpdate, … }
  return rewrite.apply(plan, ctx); // => { ...stats, updated }
}

Analytics

BoardsAnalyticsService

The admin audit surface, composed over BoardReadRepository.aggregate and BoardAggregations. createBoardsAnalyticsService(deps: { boardsRead: BoardReadRepository }).

MethodSignature
groupByEnum(field: EnumAuditField) => Promise<GroupCount[]>
topValues(field: FreeFormAuditField, limit = 20) => Promise<TopValuesResult>
dateHistogram(field: DateAuditField, bucket: "month") => Promise<DateHistogramBucket[]>
commentsStats() => Promise<CommentsStats>
themeUsage(limit = 20) => Promise<UsageResult>
sharingGroupUsage(limit = 20) => Promise<UsageResult>
boards.ts
export async function auditSnapshot(boardsRead: BoardReadRepository) {
  const analytics = createBoardsAnalyticsService({ boardsRead });
  const byLevel = await analytics.groupByEnum("level"); // [{ value, count }]
  const names = await analytics.topValues("name", 10); // { items, totalDistinct, otherCount }
  const months = await analytics.dateHistogram("createdAt", "month");
  const comments = await analytics.commentsStats();
  return { byLevel, names, months, comments };
}

Bookmarks and votes

BookmarksService and VotingService

createBookmarksService(deps: { bookmarks: BookmarksRepository; boardsRead: BoardReadRepository }) and createVotingService(deps: { votes: VotingRepository; boardsRead: BoardReadRepository }). Both take a ServiceContext last on every method and scope the repository call to ctx.userId. Adding a bookmark or setting a vote first checks the board is one the caller may read (BoardQuery.visibleTo), and refuses a missing or another account's private board with the same AccessDeniedError; removing one's own row needs no check.

ServiceMethodSignatureDescription
BookmarksServicelist(ctx) => Promise<BookmarkIds>The caller's bookmarked board IDs.
add / remove(boardId: string, ctx) => Promise<void>add throws AccessDeniedError on a board the caller may not read; remove ignores a missing bookmark.
exists(boardId: string, ctx) => Promise<boolean>
toggle(boardId: string, ctx) => Promise<boolean>Removes the bookmark when it exists, adds it otherwise under the same check as add. Returns true when added.
VotingServicelist(ctx) => Promise<UserVote[]>The caller's votes.
setVote(boardId: string, voteType: VoteType, ctx) => Promise<void>Replaces any previous vote; throws AccessDeniedError on a board the caller may not read.
removeVote(boardId: string, ctx) => Promise<void>Ignores a missing vote.
boards.ts
export function toggleBookmark(
  bookmarks: BookmarksRepository,
  boardsRead: BoardReadRepository,
  boardId: string,
  ctx: ServiceContext
): Promise<boolean> {
  return createBookmarksService({ bookmarks, boardsRead }).toggle(boardId, ctx); // true when added
}

SetVoteDTO = { boardId; voteType } is the vote input, and computeVoteDeltas drives the optimistic update on the client. The in-memory fakes createFakeBookmarksRepository() and createFakeVotingRepository() from @workspace/training/testing back the services' tests with the tables' semantics: one bookmark and one vote per (user, board).

Auction systems

AuctionSystemsService

createAuctionSystemsService(deps: { auctionSystems: AuctionSystemsRepository }): AuctionSystemsService. The catalogue is global reference data, read the same by every caller, so no method takes a ServiceContext. The API exposes it as auctionSystemsService in the authenticated context.

MethodSignatureDescription
list() => Promise<AuctionSystem[]>Every auction system, ordered by sortOrder.

Ports

BoardRepository

Command side. WriteRepository<BoardCriteria, BoardUpdatePatch, PersistableBoardWrite, BoardDeleteOptions, BoardUpdateResult, BoardDeleteResult> plus the inserts. Theme links live on BoardThemesRepository.

MethodSignatureDescription
writeOne(input: PersistableBoardWrite) => Promise<{ boardId }>Single insert. Throws on failure.
writeMany(inputs: PersistableBoardWrite[]) => Promise<BoardsWriteManyResult>Bulk insert; inserts: { code, boardId }[], failures: { code, reason }[].
overwriteMany(rows: { boardId; input: PersistableBoardWrite }[]) => Promise<BoardsWriteManyResult>In-place overwrite keeping ids; inserts lists the overwritten pairs.
update(criteria: BoardCriteria, patch: BoardUpdatePatch) => Promise<{ updatedIds }>Filtered update.
updateMany(rows: { criteria; patch }[]) => Promise<{ updatedIds }>One transaction.
delete(criteria: BoardCriteria, opts: { mode: "orphan" }) => Promise<{ deletedIds }>Orphan transfer in one transaction: the boards move to the orphan account, out of the trash, and their former owners' collection and session links are dropped. "orphan" is the only mode.

Prop

Type

BoardData is the owned-deal payload shape: an EditorBoardData whose auction and play comments are LocalizedField<RichText>. SupportedLanguage and BoardResult are re-exported from this port for convenience.

BoardThemesRepository

The board_themes junction, named after both sides. Reads take a BoardThemesQuery (empty().byBoard(id), .byBoards(ids), .byTheme(id), .byThemes(ids)) and the BoardThemesProjections.links() projection, which returns BoardThemeLink = { boardId; themeId } rows.

MethodSignatureDescription
findMany(query: BoardThemesQuery, projection: Projection<T>) => Promise<T[]>Filtered read of the link set.
writeMany(links: BoardThemeLink[]) => Promise<{ linked: number }>Bulk insert. Throws on failure.
delete(criteria: BoardThemesCriteria) => Promise<{ deletedCount: number }>Filtered delete; BoardThemesCriteria.byBoard(s) / .byTheme(s).
replaceAll(boardId: string, themeIds: readonly string[]) => Promise<void>Replaces one board's whole theme set; [] clears it. Lock and transaction inside the implementation.

BoardReadRepository

ReadRepository<BoardQuery, BoardGetByIdOptions>: getById(id, projection, opts?), findMany(query, projection), findManyPaginated(query, projection), aggregate(aggregation). getById throws NotFoundError when no row matches. BoardGetByIdOptions is { viewerUserId?; visibleTo? }: the viewer drives permissions and avatars, visibleTo applies the rule of BoardWhere.visibleTo, so an invisible board is not found.

BookmarksRepository and VotingRepository

The board_bookmarks and board_votes user ↔ board links. Reads take a BookmarkQuery or a VoteQuery (empty().byUser(id), .byBoard(id)) and the BookmarkProjections.links() or VoteProjections.votes() projection, which return BookmarkLink = { userId; boardId } and BoardVote = { userId; boardId; voteType } rows. Deletes take BookmarkCriteria or VoteCriteria (byUserAndBoard(userId, boardId), where(...)), which refuse an empty where.

PortMethodSignatureDescription
BookmarksRepositoryfindMany(query: BookmarkQuery, projection: Projection<T>) => Promise<T[]>Filtered read of the link set.
writeOne(link: BookmarkLink) => Promise<void>Insert; an existing bookmark is kept, on the (user_id, board_id) unique constraint.
delete(criteria: BookmarkCriteria) => Promise<{ deletedCount: number }>Filtered delete.
VotingRepositoryfindMany(query: VoteQuery, projection: Projection<T>) => Promise<T[]>Filtered read of the votes.
writeOne(vote: BoardVote) => Promise<void>Upsert on the (board_id, user_id) pair, replacing the vote type.
delete(criteria: VoteCriteria) => Promise<{ deletedCount: number }>Filtered delete.

AuctionSystemsRepository

Pick<ReadRepository<AuctionSystemQuery>, "findMany">: the catalogue has no write path in the product. AuctionSystemQuery.empty() reads the whole catalogue, withFilters narrows it by id or identifier; AuctionSystemProjections.entity() returns AuctionSystem rows ordered by sortOrder.

createFakeAuctionSystemsRepository(seed?) from @workspace/training/testing is its in-memory fake.

Testing

makeBoard(faker, options), makeBoardDeal(faker, overrides), makeBoardSummary, makeBookmark, makeAuctionSystem, makeDealCode and makePersistableBoardWrite come from @workspace/training/factories. makeBoard takes compact hands, auction and dealer strings plus partial metadata and board overrides.

boards.ts
export function fixture(faker: Faker): Board {
  return makeBoard(faker, {
    hands: "AKQ2.KQ3.A54.K32 / JT98.JT9.KQJ.JT9",
    auction: "1NT pass 3NT AP",
    metadata: { sharing: "public", level: 2 },
  });
}

Errors

ErrorCodeThrown by
ValidationErrorBOARD_TRANSLATION_INCOMPLETEBoardService.create / update when a deal with two or more languages has a slot missing a translation; context: { languages, gaps }
ValidationErrorBOARD_DECLARER_NOT_SOUTHBoardService.create / update when the auction (or the declared field when the auction cannot prove it) gives a non-South declarer
ValidationErrorBOARD_GATE_REJECTEDBoardService.create / update when the gate rejects; context: { rejections }
ValidationErrorVALIDATION_ERRORcomputeClaimDerivedFields without player
BoardNotInTrashErrorBOARD_NOT_IN_TRASHBoardService.hardDelete on a board that was not soft-deleted first; carries boardId
InvalidBoardReferenceErrorINVALID_BOARD_REFERENCEassertBoardsAccessible when ids are missing or not visible to the account; carries boardIds
AccessDeniedErrorACCESS_DENIEDBoardService mutations on a board the caller does not own, missing included, and theme ids that are neither system themes nor the caller's own
InvalidThemeTitlesErrorINVALID_THEME_TITLESPbnImportService.apply when a theme or inspiration label is longer than THEME_TITLE_MAX_LENGTH; carries titles
UnknownSystemThemesErrorUNKNOWN_SYSTEM_THEMESPbnImportService.apply when a row names an unknown or wrong-kind system theme; carries ids
NotFoundErrorNOT_FOUNDRead repositories' getById, hence get
InvalidCommentRuleErrornone, a DisplayableErrorCommentsRewriteService.plan on an empty find, an unknown locale, a replace or append without a value, a bad regex, or a wildcard-locale rule with a regex or wildcard find and no owner email
CommentRewriteOwnerNotFoundErrornone, a DisplayableErrorCommentsRewriteService.apply when the plan's owner email names no account

Claim and write services return rejections as data (BoardsWriteOutcome, ClaimPlan, BulkClaimStats) rather than throwing; BoardClaimService.apply and BoardService.create escalate a gate rejection to a plain Error or a ValidationError respectively. See Errors for the base classes.