Bridge Training

Ads

Table dressing of a served deal, the campaigns and the owner's own visuals that pay for it, and the upload flow of those visuals.

The ads module decides how the table of a served deal is dressed and who pays the diffusion. An advertiser campaign (AdsSponsorship) matches deals by their shape and brings its own visuals. A teacher whose plan pays the diffusion dresses the table with their own brand assets. When nobody pays, the slots go to the ad provider. The public API resolves a policy per deal, the admin app manages campaigns, the teacher app uploads visuals, and the player consumes the policy on the wire.

Import from @workspace/training/domain/ads and @workspace/training/application/ads. The two storage ports the module depends on, AdBannersStorage and BrandAssetsStorage, are cross-cutting and documented on the Ports page.

Domain

Campaigns

AdsSponsorship is an advertiser campaign. Its validity window is half-open, [validFrom, validUntil), and validUntil: null means open-ended. Campaigns are transverse: they never target an account, only a deal shape through rules.

Prop

Type

AdsCampaign is a sponsorship read with what the resolver needs: its delivered count and its active visuals.

Prop

Type

ExportDefinition
ADS_SPONSORSHIP_PRECEDENCES["over_plan", "under_plan"]. over_plan is considered before the owner's plan and pays even when the owner has headroom; under_plan is considered only when no plan pays, the last level before the provider.
AdsSponsorshipPrecedenceUnion of the above.
FunctionSignatureDescription
hasCampaignBudget(campaign: AdsCampaign) => booleanHeadroom left under servesBudget; a campaign without budget always has some.
pickCampaign(campaigns: readonly AdsCampaign[], precedence: AdsSponsorshipPrecedence, ctx: AdsSponsorshipRuleContext) => AdsCampaign | undefinedThe campaign of precedence that pays this deal: rules match, budget left, highest priority. Ties are broken by the most recent validFrom, then createdAt.
ads.ts
export function winningCampaign(faker: Faker) {
  const exhausted = makeCampaign(faker, {
    precedence: "over_plan",
    priority: 10,
    servesBudget: 100,
    delivered: 100,
  });
  const open = makeCampaign(faker, { precedence: "over_plan", priority: 1 });

  hasCampaignBudget(exhausted); // => false
  hasCampaignBudget(open); // => true: null budget never runs out

  // The exhausted campaign is skipped despite its higher priority.
  return pickCampaign([exhausted, open], "over_plan", { teachingMode: "auction" });
  // => open
}

Targeting rules

AdsSponsorshipRule is a closed discriminated union, exhaustively switched by the evaluator: adding a kind is a compile-time change everywhere.

RuleShapeMatches when
always{ kind: "always" }Every deal, including deals without a known teaching mode.
teaching-mode{ kind: "teaching-mode"; modes: TeachingMode[] }The deal's mode is one of modes.
pedagogy-group{ kind: "pedagogy-group"; groups: PedagogyGroupId[] }The deal's mode belongs to one of the pedagogy groups of @workspace/bridge/constants.

Prop

Type

Prop

Type

ExportDefinition
ADS_SPONSORSHIP_RULE_KINDS["always", "teaching-mode", "pedagogy-group"]
DEFAULT_ADS_SPONSORSHIP_RULES{ match: "any", rules: [{ kind: "always" }] }, matches every deal.
PEDAGOGY_GROUP_ID_VALUESThe PedagogyGroupId values accepted by a pedagogy-group rule.
FunctionSignatureDescription
evaluateAdsSponsorshipRules(rules: AdsSponsorshipRules, ctx: AdsSponsorshipRuleContext) => booleanmatch: "any" passes as soon as one rule matches, match: "all" needs every rule. An empty rule list is false whatever match. A null teaching mode never matches teaching-mode or pedagogy-group.
validateAdsSponsorshipRules(value: unknown) => { ok: true; value: AdsSponsorshipRules } | { ok: false; error: AdsSponsorshipRulesValidationError }Structural validator of an untrusted payload. Returns the normalized rules, or a { field, reason } pair the caller translates. Reasons: must_be_object, invalid_match, must_be_array, must_be_non_empty_array, invalid_teaching_mode, invalid_pedagogy_group, invalid_kind.
ads.ts
const rules: AdsSponsorshipRules = {
  match: "any",
  rules: [{ kind: "teaching-mode", modes: ["petit-bridge"] }],
};

evaluateAdsSponsorshipRules(rules, { teachingMode: "petit-bridge" }); // => true
evaluateAdsSponsorshipRules(rules, { teachingMode: null }); // => false: no known mode
evaluateAdsSponsorshipRules({ match: "any", rules: [] }, { teachingMode: null }); // => false

validateAdsSponsorshipRules({ match: "all", rules: [{ kind: "always" }] });
// => { ok: true, value: { match: "all", rules: [{ kind: "always" }] } }
validateAdsSponsorshipRules({ match: "any", rules: [{ kind: "teaching-mode", modes: [] }] });
// => { ok: false, error: { field: "rules.rules[0].modes", reason: "must_be_non_empty_array" } }

Policy

AdsPolicy is the decision shipped on the wire for one deal. The v2 DealDtoV2 carries the full policy under ads; the legacy v1 DealDto only projects the sponsor name as adprovider through adProviderFromPolicy.

VariantFieldsMeaning
{ kind: "default" }Every slot goes to the ad provider.
{ kind: "sponsored"; ... }sponsor?, creatives, clickUrl?, alt?, fallbackVisuals to place. sponsor is the third-party identity, absent when the visuals are the deal owner's own. creatives is a flat list with no slot assignment: the player picks per slot by orientation and size fit, and may reuse one visual across slots.

Prop

Type

ExportDefinition
ADS_CREATIVE_ORIENTATIONS["vertical", "horizontal"]. A stored field, never derived from the width/height ratio, so a near-square visual is classified by its uploader.
ADS_FALLBACKS["provider", "none", "house"]: what a slot does when no visual fits it. provider hands it to the ad network, none leaves it empty, house shows a Bridge Training visual and never the provider.
DEFAULT_ADS_POLICY{ kind: "default" }
HOUSE_ADS_POLICY{ kind: "sponsored", creatives: [], fallback: "house" }: a plan-paid table without visuals of its own.

Brand assets

BrandAsset is one table-dressing visual for one fixed slot, owned by an account or by a campaign. Rails are duplicated left and right; there is no per-side asset, and several active rows may share a slot.

Prop

Type

Prop

Type

ExportDefinition
BRAND_ASSET_SLOTS["top", "rail-160", "rail-300"]
BRAND_ASSET_SLOT_SPECSReference size and orientation per slot: top 320x50 horizontal, rail-160 160x600 vertical, rail-300 300x600 vertical. Rendering scales a served file to its slot.
BRAND_ASSET_STATUSES["active", "disabled"]
BrandAssetOwner{ kind: "account"; accountId } or { kind: "campaign"; sponsorshipId }
FunctionSignatureDescription
slotForCreative(creative: { orientation: AdsCreativeOrientation; width: number }) => BrandAssetSlotHorizontal lands in top; vertical up to 160 wide in rail-160, wider in rail-300.
storageKeyFromPublicUrl(imageUrl: string) => stringBucket-qualified key of a public storage URL; any other URL is its own key.
brandAssetToCreative(asset: BrandAsset) => AdsCreativeWire projection: orientation from the slot spec, dimensions from the row, clickUrl and alt only when not null.
isCompleteBrandAssetSet(assets: readonly BrandAsset[]) => booleanEvery slot has at least one active asset.

Upload rules

The upload path accepts three image types under a weight limit, and ties every stored object to one account and one target through a bucket-qualified key brand-assets/<accountId>/<slot or source>/<id>.<ext>.

ExportDefinition
BRAND_ASSETS_BUCKET"brand-assets"
BRAND_ASSET_CONTENT_TYPES["image/png", "image/jpeg", "image/webp"]
BRAND_ASSET_EXTENSIONSpng, jpg, webp per content type.
BRAND_ASSET_MAX_BYTES2 MiB.
BRAND_ASSET_UPLOAD_KINDS["slot", "source"]. A source upload is the original kept to crop again, never served.
BRAND_ASSET_REJECTIONS["unsupported_type", "too_large", "wrong_dimensions"]
BrandAssetUploadTarget{ kind: "source" } or { kind: "slot"; slot: BrandAssetSlot }

Prop

Type

FunctionSignatureDescription
isBrandAssetContentType(value: string | null) => value is BrandAssetContentTypeGuard over the accepted types.
validateBrandAssetImage(image: StoredBrandAssetImage, slot: BrandAssetSlot | null) => BrandAssetImageValidationOrder: type, weight, dimensions. A slot upload must be an integer scale (1x or 2x) of the slot's reference size; a null slot (source upload) skips the dimension check. Returns { ok: true, image: ValidBrandAssetImage } or { ok: false, reason }.
brandAssetStorageKey(accountId: string, target: BrandAssetUploadTarget, id: string, contentType: BrandAssetContentType) => stringThe bucket-qualified key of a new object.
storageKeyBelongsTo(accountId: string, target: BrandAssetUploadTarget, key: string) => booleanTrue when key is one object directly under the account's folder for that target.
ads.ts
const stored: StoredBrandAssetImage = {
  size: 120_000,
  contentType: "image/png",
  width: 640,
  height: 100,
};
validateBrandAssetImage(stored, "top"); // => { ok: true, image: { ... } } (2x of 320x50)
validateBrandAssetImage(stored, "rail-160"); // => { ok: false, reason: "wrong_dimensions" }
validateBrandAssetImage({ ...stored, size: 3_000_000 }, null); // => { ok: false, reason: "too_large" }

slotForCreative({ orientation: "horizontal", width: 728 }); // => "top"
slotForCreative({ orientation: "vertical", width: 160 }); // => "rail-160"
slotForCreative({ orientation: "vertical", width: 300 }); // => "rail-300"

const key = brandAssetStorageKey("acc-1", { kind: "slot", slot: "top" }, "obj-1", "image/png");
// => "brand-assets/acc-1/top/obj-1.png"
storageKeyBelongsTo("acc-1", { kind: "slot", slot: "top" }, key); // => true
storageKeyBelongsTo("acc-2", { kind: "slot", slot: "top" }, key); // => false

export function completeSet(faker: Faker): boolean {
  const top = makeBrandAsset(faker, { slot: "top" });
  brandAssetToCreative(top); // => { orientation: "horizontal", imageUrl, width: 320, height: 50 }

  const assets: BrandAsset[] = [
    top,
    makeBrandAsset(faker, { slot: "rail-160" }),
    makeBrandAsset(faker, { slot: "rail-300" }),
  ];
  return isCompleteBrandAssetSet(assets); // => true
}

Ad banners

AdBanner is a single image in a sponsor's bucket folder, picked by the admin. Placement is assigned at pick time, not from the filename.

Prop

Type

Application

AdsPolicyResolver

createDbAdsPolicyResolver(deps: AdsPolicyResolverDeps): AdsPolicyResolver

One decision per deal: the policy and who pays. Five levels, the first that applies wins.

  1. The identified player holds the ad_free feature. House visuals only, whatever the campaigns and the owner's plan; the viewer pays, nothing is debited.
  2. An over_plan campaign whose rules match and budget is left. Campaign visuals, uncovered slots go to the provider, the campaign pays.
  3. The owner's plan has diffusion headroom (ServeDiffusionService.decideDiffusion). The owner's brand assets, uncovered slots show the house visuals, the plan pays.
  4. An under_plan campaign, same as 1, considered only when no plan pays.
  5. DEFAULT_ADS_POLICY, nobody pays.

Only resolveForServe may reach level 2: without a serve nothing is debited, so resolveWithoutServe walks 0, 1, 3, 4 and resolveForUsers 1, 3, 4. Campaigns are loaded once per call. Each read is contained: a failing viewer read counts as an anonymous viewer, a failing campaign load counts as no campaign, a failing diffusion decision keeps the plan paying with the house visuals at the clock instant, and failing brand assets keep the plan paying with the house visuals. Failures are reported to onError with their stage.

MethodSignatureDescription
resolveForServe(input: AdsResolveInput) => Promise<AdsResolution>The decision a v2 deal serve is minted from: { policy, payer }.
resolveWithoutServe(input: AdsResolveInput) => Promise<AdsPolicy>Policy for a read that mints no serve: ad-free viewer, campaigns or provider, never the plan.
resolveForUsers(userIds: (string | null | undefined)[], ctx: AdsSponsorshipRuleContext) => Promise<Map<string, AdsPolicy>>Batch variant for endpoints returning many deals. One entry per real id, the same policy for every id since campaigns are transverse. Null, undefined and empty ids are skipped.

Prop

Type

Prop

Type

ExportDefinition
AdsPayer{ kind: "campaign"; sponsorshipId }, { kind: "plan"; diffusionDecidedAt: Date }, { kind: "viewer"; viewerAccountId } or { kind: "none" }
AdsResolution{ policy: AdsPolicy; payer: AdsPayer }
AdsResolveStage"viewer" | "campaigns" | "diffusion" | "brand-assets", the stage reported to onError.
adProviderFromPolicy(policy)string | null: the sponsor name the legacy player consumes as adprovider; null for the default policy or the owner's own visuals.
createNoopAdsPolicyResolver()Test-only resolver: default policy, nobody pays, so unit tests off the sponsorship path need no deps.
ads.ts
export async function resolveDealAds(
  deps: AdsPolicyResolverDeps,
  ownerAccountId: string | null,
  viewerAccountId: string | null
) {
  const resolver = createDbAdsPolicyResolver({
    ...deps,
    onError: (stage, error) => console.error(`ads resolve failed at ${stage}`, error),
  });

  // Mints a serve: an ad_free viewer pays first, else the owner may be debited.
  const { policy, payer } = await resolver.resolveForServe({
    ownerAccountId,
    viewerAccountId,
    ctx: { teachingMode: "auction" },
  });
  // payer => { kind: "viewer", viewerAccountId } | { kind: "campaign", sponsorshipId }
  //        | { kind: "plan", diffusionDecidedAt } | { kind: "none" }

  // Read without a serve: ad-free viewer, campaigns or provider, never the plan.
  const readOnly = await resolver.resolveWithoutServe({
    ownerAccountId,
    viewerAccountId,
    ctx: { teachingMode: null },
  });
  return { policy, payer, readOnly };
}

BrandAssetsService

createBrandAssetsService(deps: BrandAssetsServiceDeps): BrandAssetsService

The upload flow of a deal owner's visuals: mint a signed upload ticket per object, then on confirm read the stored bytes back, validate them, and store the full set or replace one slot. Every storage read happens before the unit of work opens, so no transaction is held across HTTP.

MethodSignatureDescription
createUploadTicket(input: CreateBrandAssetUploadInput) => Promise<BrandAssetUploadTicket>Mints a key under the account's folder for the target and asks storage for a signed upload URL. Throws ValidationError (VALIDATION_ERROR, field contentType) for a type outside the accepted set, without touching storage.
confirmSet(input: ConfirmBrandAssetSetInput) => Promise<BrandAsset[]>Checks every key belongs to the account and target, reads and validates each slot object and the optional source, then in one transaction disables the active set and inserts the new one. Throws AccessDeniedError for a foreign key, ValidationError (BRAND_ASSET_INVALID, { slot, reason }) for a missing or invalid object. Stored dimensions are the slot's reference size whatever the file resolution.
replaceSlot(input: ReplaceBrandAssetSlotInput) => Promise<BrandAsset>Same checks for one slot. Throws ConflictError (BRAND_ASSET_SET_MISSING) when the account has no complete active set. Carries the previous row's clickUrl and alt, and its source when none is given.
listForAccount(accountId: string) => Promise<BrandAsset[]>Every generation through the unit of work, in slot order, active first then newest first.

Prop

Type

Prop

Type

Prop

Type

Prop

Type

ads.ts
export async function uploadOwnVisuals(deps: BrandAssetsServiceDeps, accountId: string) {
  const brandAssets = createBrandAssetsService(deps);

  // 1. One ticket per object; the browser PUTs the bytes to `uploadUrl`.
  const ticket = await brandAssets.createUploadTicket({
    accountId,
    target: { kind: "slot", slot: "top" },
    contentType: "image/png",
  });
  // ticket => { storageKey: "brand-assets/<accountId>/top/<id>.png", uploadUrl, token, expiresAt }

  // 2. Confirm the full set: every slot is read back and validated before the write.
  return brandAssets.confirmSet({
    accountId,
    sourceStorageKey: null,
    clickUrl: "https://example.org",
    alt: "Our club",
    slots: {
      top: { storageKey: ticket.storageKey },
      "rail-160": { storageKey: "brand-assets/<accountId>/rail-160/<id>.png" },
      "rail-300": { storageKey: "brand-assets/<accountId>/rail-300/<id>.png" },
    },
  });
}

AdsSponsorshipsAdminService

createAdsSponsorshipsAdminService(deps: AdsSponsorshipsAdminServiceDeps): AdsSponsorshipsAdminService

Admin CRUD over campaigns and their visuals, with input validation, composed over the campaign repositories: deps is { sponsorships: AdsSponsorshipsRepository; sponsorshipsRead: AdsSponsorshipReadRepository }. Every failure is a ValidationError with code VALIDATION_ERROR and a { field, reason } context.

MethodSignatureDescription
list() => Promise<AdsSponsorship[]>Every sponsorship, most recent validFrom first; expired filtering is up to the caller.
listWithUsage() => Promise<AdsSponsorshipWithUsage[]>Each sponsorship with the diffusions it has paid, latest end first, open-ended campaigns leading.
getById(id: string) => Promise<AdsSponsorship | null>Null for an unknown id.
listVisuals(sponsorshipId: string) => Promise<BrandAsset[]>The campaign's active visuals, in creation order.
create(input: PersistableAdsSponsorshipWrite) => Promise<AdsSponsorship>Trims sponsor (reason empty), checks dates (invalid_date) and validUntil > validFrom (must_be_after_valid_from), validates rules, precedence (invalid_precedence), integer priority (invalid_priority), positive integer or null servesBudget (invalid_serves_budget) and each visual (invalid_visual, invalid_orientation, invalid_image_url, missing_dimensions). Omitted visuals become [].
update(id: string, patch: AdsSponsorshipPatch) => Promise<AdsSponsorship | null>Same rules on the given fields; the window order is checked against the current row. An empty patch is rejected (form, empty_patch). Returns null for an unknown id.
delete(id: string) => Promise<void>Idempotent.

Prop

Type

Prop

Type

Prop

Type

ads.ts
export function createCampaign(deps: AdsSponsorshipsAdminServiceDeps) {
  const admin = createAdsSponsorshipsAdminService(deps);
  return admin.create({
    sponsor: "  Acme Bridge  ", // trimmed to "Acme Bridge"
    precedence: "over_plan",
    priority: 5,
    servesBudget: 10_000,
    validFrom: new Date("2026-09-01T00:00:00Z"),
    validUntil: null, // open-ended
    rules: { match: "any", rules: [{ kind: "always" }] },
    visuals: [
      {
        orientation: "horizontal",
        imageUrl: "https://cdn.example.org/acme-320x50.png",
        width: 320,
        height: 50,
        clickUrl: "https://acme.example.org",
        alt: null,
      },
    ],
  });
}

AdBannersService

createAdBannersService(deps: { storage: AdBannersStorage }): AdBannersService

MethodSignatureDescription
listForSponsor(sponsorSlug: string) => Promise<AdBanner[]>Delegates to the storage port. An empty slug resolves to [] without a storage call. Slug derivation belongs to the storage adapter.
ads.ts
export function listSponsorBanners(storage: AdBannersStorage, sponsorSlug: string) {
  const banners = createAdBannersService({ storage });
  return banners.listForSponsor(sponsorSlug); // => [] for an empty slug, without a storage call
}

Ports

Campaign repositories. Campaigns bind the canonical read and write surfaces of Queries, like themes. The resolver and the admin service compose a query object, a criteria and a projection; no port carries a business-named method.

ExportBindsNotes
AdsSponsorshipWhereWhereShape{ id?: Equality<string>; activeAt?: Date }. activeAt keeps the campaigns whose half-open window contains the instant, open-ended ones included.
AdsSponsorshipSort, AdsSponsorshipSortColumn, ADS_SPONSORSHIP_SORT_COLUMNSTSortpriority, validFrom or validUntil, ties broken by the most recent validFrom, then createdAt. A null validUntil sorts as the latest end. Without a sort, most recent validFrom first.
AdsSponsorshipQueryQueryObject<AdsSponsorshipQueryParams>Static empty() and active(at); withId, withIds, activeAt, withPagination, withSorting, withFilters.
AdsSponsorshipCriteriaCriteria<AdsSponsorshipWhere>Static byId, byIds, where; instance and.
AdsSponsorshipProjectionsProjection<T>entity() is the AdsSponsorship row. campaign() is the AdsCampaign the resolver needs: delivered count and active visuals in creation order. withUsage() is the AdsSponsorshipWithUsage of the admin list, delivered 0 without a deliveries row.
AdsSponsorshipAggregationsAggregation<T>count(criteria?).
AdsSponsorshipPatchTPatchEvery column optional; validUntil: null clears the end date; visuals replaces the active set in one transaction with the row update.
AdsSponsorshipReadRepositoryReadRepository<AdsSponsorshipQuery>getById throws NotFoundError for an unknown id.
AdsSponsorshipsRepositoryWriteRepository<AdsSponsorshipCriteria, AdsSponsorshipPatch, PersistableAdsSponsorshipWrite>Plus writeOne(input) => Promise<{ sponsorshipId }>, which writes the visuals in the same transaction. delete cascades to the campaign's visuals and deliveries.
ads.ts
export async function readAndWriteCampaigns(
  sponsorshipsRead: AdsSponsorshipReadRepository,
  sponsorships: AdsSponsorshipsRepository,
  now: Date
) {
  // What the resolver loads: the campaigns in their window, resolver order.
  const active = await sponsorshipsRead.findMany(
    AdsSponsorshipQuery.active(now).withSorting({ column: "priority", desc: true }),
    AdsSponsorshipProjections.campaign()
  ); // => AdsCampaign[], each with `delivered` and its active `assets`

  // What the admin list shows: every campaign with its paid diffusions.
  const listed = await sponsorshipsRead.findMany(
    AdsSponsorshipQuery.empty().withSorting({ column: "validUntil", desc: true }),
    AdsSponsorshipProjections.withUsage()
  ); // => { sponsorship, delivered }[]

  // A criteria scopes every write; `visuals` replaces the active set.
  const [first] = listed;
  if (first) {
    await sponsorships.update(AdsSponsorshipCriteria.byId(first.sponsorship.id), {
      priority: 3,
      visuals: [],
    }); // => { updatedIds: [id] }, [] for an unknown id
  }
  return active;
}

Brand asset repositories. A deal owner's visuals bind the canonical surface too, picking what the upload flow and the resolver use: findMany on the read side, update and writeMany on the write side. A generation is never edited in place: disabling is an update of status under a criteria, a new generation is a writeMany. Disabling the previous generation and inserting the new one must commit or roll back together, so the database layer exposes BrandAssetsUnitOfWork.run(step) executing one step in a single transaction over a BrandAssetsTx = { assets: BrandAssetsRepository; assetsRead: BrandAssetsReadRepository }. The unit of work is a transaction scope, not a repository. A campaign's visuals live in the same table but are written with their campaign, through AdsSponsorshipsRepository; the brand asset write side only writes account-owned rows.

ExportBindsNotes
BrandAssetWhereWhereShape{ id?, accountId?, slot?, status? }, each an Equality<…>. accountId never matches a campaign-owned row.
BrandAssetSort, BrandAssetSortColumn, BRAND_ASSET_SORT_COLUMNSTSortcreatedAt only. Whatever the sort, rows come in slot order, active first; the column orders within that. Without a sort, newest first.
BrandAssetQueryQueryObject<BrandAssetQueryParams>Static empty(), ofAccount(accountId) (every generation) and servedFor(accountId) (the active set, oldest first within a slot, the order the resolver serves); active, withSorting, withFilters.
BrandAssetCriteriaCriteria<BrandAssetWhere>Static activeOfAccount(accountId, slots), where no slot matches nothing, and where; instance and.
BrandAssetProjectionsProjection<T>entity(), the BrandAsset.
BrandAssetPatchTPatch{ status?: BrandAssetStatus }.
BrandAssetsReadRepositoryPick<ReadRepository<BrandAssetQuery>, "findMany">
BrandAssetsRepositoryPick<WriteRepository<BrandAssetCriteria, BrandAssetPatch, PersistableBrandAssetWrite>, "update">Plus writeMany(rows) => Promise<BrandAsset[]>, the written rows in slot order.

Prop

Type

The storage ports AdBannersStorage (banner listing per sponsor folder) and BrandAssetsStorage (signed upload URL, read back of a stored image, public URL) are documented with the other cross-cutting Ports.

Errors

ErrorCodeThrown by
ValidationErrorVALIDATION_ERRORAdsSponsorshipsAdminService on any malformed input, BrandAssetsService.createUploadTicket on an unsupported content type. Context { field, reason }.
ValidationErrorBRAND_ASSET_INVALIDBrandAssetsService.confirmSet and replaceSlot when a stored object is missing or fails validateBrandAssetImage. Context { slot, reason }, slot null for the source.
AccessDeniedErrorACCESS_DENIEDBrandAssetsService when a storage key is not under the account's folder for the target.
ConflictErrorBRAND_ASSET_SET_MISSINGBrandAssetsService.replaceSlot without a complete active set.
UnexpectedErrorUNEXPECTED_ERRORBrandAssetsService.replaceSlot when the insert returns no row.

See Errors for the base classes.