Membership stops governing how much a creator may charge and starts governing how often they may put a new price on something — article 33749, plus the decisions recorded in docs/licensing-fee-revamp-questions.md. - Price caps deleted. The licensing fee ceiling is a flat 100/generation for every creator (500 on a video model); paid access has no ceiling at all. Every clamp goes with them, including the cap-tier Redis cache — getViewerMonetization, the purchase path and the orchestrator-facing mini endpoint no longer resolve a subscription tier at all. - Membership instead grants a monthly allowance of NEW prices: free 3, bronze 10, silver 25, gold unlimited, counted in a new PricingSlot ledger keyed like PaidAccess. One slot per entity, spent on first pricing, never returned. - A creator score of 10,000 is required to apply a price to something that has none. Moderators are not exempt from the floor or the allowance; they remain exempt from the fee ceiling. - Both rules gate only the unpriced → priced transition. Editing, lowering or clearing a price that is already set is always free, at any score — which grandfathers everything priced before these rules existed, and is why the cutover needs no backfill. Creator Studio enforces both rules itself: its fee and gate writes are direct SQL that never reaches the service layer, so a rule not applied there is a rule the spoke can bypass. The rules themselves live in @civitai/buzz so the two implementations cannot drift on a threshold or a message. Also reverts the unshipped lapse-grace work: it mitigated lapse-repricing, and a lapse no longer changes any price. MIGRATION — 20260821120000_pricing_slot is applied by hand, per environment, and must land BEFORE this code deploys: until the table exists every first-time pricing write throws on a missing relation, and the Creator Studio models page 500s outright. Apply it under a short lock_timeout — the inline owner FK takes SHARE ROW EXCLUSIVE on "User". See the migration header. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
17 KiB
Unifying licensing fees + paid access into @civitai/monetization
Symbol names in Steps 3–5 predate the 2026-08-21 monetization revamp:
getCapTiers,capTierCache,assertPaidAccessCaps,countUserPermanentAccessVersions,strictestCapMediaTypeandcurrentAccessPricesno longer exist. The shapes of those steps survive — read them as "the cache/count/guard layer", and substitutepricing-slot.service.ts/pricing-slot.ts/assertMonetizationWrite.
Status: Plan — not started. Sequenced so every step ships independently.
Why
@civitai/buzz already shares the pure monetization rules — the fee ceiling, the pricing allowance
(monthlyPricingAllowance, pricingAllowanceState), isPermanentGate, gatePrices. Everything that
touches the database or a cache is written twice: once in the main app against Prisma, once in the
spoke against kysely.
That seam produced four money bugs in one week. This is the argument for the work — not tidiness:
| Bug | Cause |
|---|---|
| Fee set in Creator Studio kept earning tips (CU 868kk4j2k) | Main app maintained DisablePayout alongside the fee; the spoke's writeFee never touched flags |
| "Fee set" filter listed 64,485 versions whose chip read "Fee off" | Spoke filtered IS NOT NULL; every read gates > 0 |
| Licensing fee clamped for a gold member | Tier read from a cache the spoke can't bust |
| Floor + allowance enforced twice (2026-08-21) | pricing-slot.service.ts and the spoke's pricing-slot.ts are independent implementations of the same rule — the seam this plan exists to close, added again |
Cleared fee stored 0, not NULL |
Two clear paths, two normalizations |
Each was fixed in both places by hand. The next one will be too, unless the DB/cache layer is shared.
The divergence that matters most
The two apps resolve the cap tier from different sources. The main app queries
CustomerSubscription (getCapTier, Redis-cached); the spoke reads user.tier / memberInBadState
off the session. They can disagree for as long as either cache is stale, and when they do the creator's
editor shows one ceiling while the charge path applies another. Both delegate to the same
resolveCapTier, which hides the disagreement: the rule is shared, the input is not.
Sharing the rule was not enough. The input has to be shared too.
Decision record: package, not a service
Considered standing up a monetization API server instead. Rejected, for three reasons worth recording so this isn't relitigated:
- The service can't be extracted without the data. Fees,
DisablePayoutand paid-access terms live onModelVersion/PaidAccessin the main Postgres, beside the model and its files. A service reading the main app'sModelVersionis a distributed monolith — a network hop plus the shared-DB coupling. Extracting the data instead means the main app can no longer join fees into a version query, which is most of what it does. (Contrast the buzz service, a correct extraction: it owns the ledger, its API is coarse, and consumers tolerate staleness.) - Every service boundary in a pricing domain creates a far-side cache, and each is a silent
correctness liability. The orchestrator is the live proof: it caches resource data because it can't
call per generation, and when
invalidateResourcefell out of the generated client that cache became un-bustable — mispricing every affected model for ~2.5 months with no signal.getPaidAccessandgetCapTiersare per-row on the feed and generation paths, so the consumers that would hurt most are exactly the ones that would be forced to cache. - It pre-empts a decision that has its own forum.
docs/event-bus-discovery.mdexists to replace "a sprawl of little backend services-behind-endpoints" with one substrate, and is on hold pending leadership air-cover.
A package fixes modularity; a service fixes deployment and ownership coupling. The pain here is modularity.
When a service would be right: if monetization ever owns its own store, with ModelVersion holding
only a reference. That's a data-model project, and it belongs in the event-bus conversation.
Target layout
@civitai/buzz today mixes two domains. Splitting them along how they're actually used is nearly clean:
| uses currency/client symbols | uses monetization symbols | |
|---|---|---|
| main app (24 importers) | 2 | 20 |
| spoke (9 importers) | 1 | 7 |
@civitai/buzz currency transport — buzz service client, account-types,
queries, responses, env (BUZZ_ENDPOINT). 3 consumers.
@civitai/monetization licensing fees, paid access, caps, limits, creator program.
├── (root) pure rules — client-safe, no IO
└── /server kysely queries + redis caches + write guards
Enforced by exports in package.json, so @civitai/monetization cannot resolve a server module —
stronger than the discipline a naming convention buys. @civitai/buzz keeps meaning what its name says.
The /server entrypoint constructs nothing: both apps already build kysely (@civitai/db/kysely)
and Redis (@civitai/redis) clients, so they're passed in. No env reading, no singletons, and tests
inject fakes.
packages/civitai-monetization/src/
index.ts pure re-exports (licensing-fee, paid-access rules, monetization-limits, media-type, creator-program)
server/
context.ts MonetizationContext = { db: Kysely<DB>, redis, sysRedis }
cap-tier.ts getCapTier / getCapTiers / bustCapTier
paid-access.ts getPaidAccess / write / count / bust
licensing-fee.ts setFee (fee + type + currency + DisablePayout, one statement) / bulk / preview
guards.ts assertPaidAccessCaps / assertFeeCaps
filters.ts feeFilter / paidAccessFilter kysely fragments
Prerequisite: move the batched cache builder to @civitai/redis
paidAccessCache and capTierCache are createCachedObject — batched per-id (fetch(ids[]) → Record), with msgpack packing, request debounce, notFound markers, optional per-pod L1, and fail-open
on Redis errors. It lives in src/server/utils/cache-helpers.ts, main-app only. @civitai/redis today
exports only a single-key read-through cache, which can't express "fetch 200 version ids in one
round trip" — what getPaidAccess and getCapTiers do on the feed path.
Move createCachedArray / createCachedObject into @civitai/redis. A second, narrower batched
primitive would mean two implementations of the same semantics — the exact duplication this document
exists to remove.
Smaller than the file's 1,044 lines suggests:
- The redis dependency is already in the package.
~/server/redis/clientis a shim that doesexport * from '@civitai/redis/client'; the package already owns the typed client,REDIS_KEYSand the key-template types. - Only the two builders move (~470 lines).
queryCache(db: PrismaClient), tag busting, pattern clearing, counters andfetchThroughCachestay. - What's left to inject is small: five prom counters,
logToAxiom/logSysRedisFailOpen/createLogger, andCacheTTL. The package already takes injected behavior (the client's debug logger and Flipt policy). - Small utils come along:
createLruCache,sleep,hashifyObject,isDefined.
Measured test blast radius:
| suites | |
|---|---|
mock ~/server/redis/client |
155 |
mock ~/server/utils/cache-helpers |
26 |
reference createCachedObject/Array in their mock factory |
4 |
cache-helpers.ts keeps re-exporting both builders, so all 26 module-path mocks keep working. The 155
mocking the redis client stay unaffected only if the builder receives its client as an argument
rather than importing one. That is the single design constraint that keeps this cheap.
Implementation steps
Branching. Step 0 is its own PR — it's a monorepo-wide caching primitive, not monetization work,
with a different reviewer and its own test blast radius, and it's worth having even if the rest is
abandoned. Steps 1–6 land together on one branch off main.
Not one PR per step: the repo forbids stacked PRs, so N PRs means N serialized merge waits, each rebasing on the last. Steps 1–6 are also one coherent end state — a half-migrated layer, with logic live in both the package and the app, is a worse place to sit than either bookend, and the package's interface emerges as things move, so an interface fixed in Step 2 and invalidated by Step 5 is churn paid twice.
Keep each step as its own commit. That preserves commit-by-commit review and a bisectable history if a money bug reaches prod, without the serialized merges. Step 1 in particular should be a commit of only moves and import rewrites, so review attention goes to Steps 4–5 where money logic changes.
Every step leaves the tree green: pnpm typecheck + the spoke's pnpm run check + pnpm test.
Step 0 — cache builder to @civitai/redis
- Add
createCachedArray/createCachedObjecttopackages/civitai-redis/src/cached-array.ts, taking{ redis, sysRedis, metrics, log }at construction. - Move
createLruCache,sleep,hashifyObject,isDefined(or copy the two small ones). - In
src/server/utils/cache-helpers.ts, delete both builders and re-export the package versions pre-bound to the app's client, counters and loggers. - Verify: the 4 suites whose mock factory names the builders; then the full suite.
Done when: no behavior change, cache-helpers is ~470 lines lighter, and both builders are
importable from @civitai/redis with an injected client.
Step 1 — create the package and split @civitai/buzz
- Scaffold
packages/civitai-monetizationwithexports: { ".": …, "./server": … }. - Move
licensing-fee.ts,paid-access.ts,monetization-limits.ts,media-type.ts,creator-program.ts(+ their tests) fromcivitai-buzzto the new package root. - Leave
client.ts,env.ts,queries.ts,responses.ts,account-types.tsin@civitai/buzz. - Update 27 import statements (20 main-app + 7 spoke files). Mechanical:
@civitai/buzz→@civitai/monetization. The 3 currency/client importers are untouched. - Add
@civitai/monetizationto both apps'package.json.
Done when: typecheck + svelte-check green, no /server code exists yet. Pure move, no logic
change — keep it reviewable by making this commit contain only moves and import rewrites.
Step 2 — cap tier (the divergence)
server/cap-tier.ts: portgetCapTierfromsubscriptions.service.tsto kysely (CustomerSubscription⋈Product,status NOT IN (...),renewalEmailSentfilter,pickHighestTier), plusgetCapTierson the Step-0 batched cache and an exportedbustCapTier.- Main app:
paid-access.service.tsdelegatesgetCapTiers/getCachedCapTierto the package (2 call-site files each);subscriptions.service.tskeepsgetHighestTierSubscriptionfor non-cap uses (5 files referencegetCapTier— confirm which are cap-related). clearSessionCachecallsbustCapTier(userId)instead of rebuilding the Redis key by hand.- Spoke stops reading tier off the session —
cappedTier(resolveMembership(...))becomes a package call.membership.tskeepsisCreatorProgramMemberand the test-cookie override.
Done when: both apps resolve the cap tier from one DB-backed, one-cache definition. This closes the gold-membership class of bug and is a read path, so a mistake is visible without being destructive.
Watch: adds a cached DB read to Studio pages showing caps (1h TTL, bust on subscription change → one Redis get per page). See open question 2.
Step 3 — reads and filters
server/paid-access.ts:getPaidAccess+ its cache, kysely-backed (8 call-site files).server/filters.ts:feeFilterandpaidAccessFilteras kysely fragments, moved out ofapps/creator-studio/src/lib/server/models.ts. Keep the parenthesization — theoffbranch is OR'd and reassociates if unwrapped.- Main app
getViewerMonetization(6 call-site files) becomes a thin wrapper over the package. countUserPermanentAccessVersions(1 file) and the spoke'scountPermanentAccessVersions/countPermanentAccessVersionsExcludingcollapse into one function with anexcludeparam.strictestCapMediaTypeand per-rowcapMediaTypeunify.
Done when: "fee set" cannot mean two things again, and the permanent-gate count has one definition.
Step 4 — write guards
server/guards.ts: oneassertPaidAccessCaps(3 call-site files today) and a newassertFeeCaps, both preserving the increase-only rule (raisesOverCap— resubmitting or lowering always passes; this is the outage82f64846bahot-fixed).- Callers: the tRPC handler, the REST endpoint, and both spoke form actions in
models/+page.server.ts(which today enforce caps inline). currentAccessPricesmoves in as the guards' read.
Done when: three copies of the cap rule become one. Requires open question 1 (guards read current state, so they sit on the write path).
Step 5 — writes
server/licensing-fee.ts: onesetFeeowning the invariant "a fee and its payout flag move together" — fee,licensingFeeType,licensingFeeSettlementCurrencyandDisablePayoutin a single statement. Absorbs the spoke'swriteFee/normalizeFee/ bulk paths and the main app's fee block inupsertModelVersion.server/paid-access.ts: gate write absorbingwritePaidAccessForModelVersion(2 files),setPaidAccessConfig,bulkSetPermanentAccess,materializePaidAccessEndsAt.- One fee-input schema replaces
modelVersionUpsertSchema2.licensingFeeand the spoke'slicensingFeeRatioSchema+normalizeFee. - Keep the
feeProvidedguard:undefined(caller sent no fee) must stay distinguishable fromnull/0(creator cleared it), orrequestReviewHandler/declineReviewHandlerwipe fees on partial saves. Port the regression test alongside.
Done when: the CU 868kk4j2k class of bug is structurally impossible.
Step 6 — cache busting
bustMonetizationCaches(versionIds)for the caches the package owns (paid access, cap tier).- Main app
bustMvCachekeeps app-specific work — search index, CDN purge,dataForModelsCache,bustOrchestratorModelCache— and calls the package for the rest. - Spoke may bust directly instead of HTTP-hopping
/api/v1/model-versions/bust-cache. Keep the endpoint until the spoke's Redis access is proven in production; this is the least reversible step.
Out of scope
- Orchestrator invalidation stays in the main app — needs
ORCHESTRATOR_ACCESS_TOKENand the resource-data cache, neither of which belongs here. - Search-index and CDN busting stay app-side.
- The 67k legacy
licensingFee = 0rows. Cosmetic now (every read gates> 0, filter fixed). Optional separate migration. @civitai/buzz's currency client. Untouched beyond losing the monetization modules.
Open questions
@ai:* The fork in the road. upsertModelVersion writes licensingFee and flags inside a Prisma
$transaction alongside files, images and recommended resources. A kysely write cannot join that
transaction — separate connections, so the fee write would commit outside the version write's
atomicity. Options: (a) move the fee/flag write to a package call immediately after the transaction
commits — simplest, and the spoke already lives with that window; (b) share a connection — not viable,
Prisma won't hand over its pool; (c) the package returns SQL builders each app executes — preserves
atomicity but costs the package its caching, which is half the point. Recommend (a). Steps 4–5
depend on this answer.
@ai:* Step 2 moves the spoke's tier from session → DB, adding one cached Redis get per Studio page
that shows caps. Acceptable?
@ai:* The Steps 1–6 branch is long-lived and will conflict with anything touching monetization files
(Step 1 alone rewrites 27 import statements). Worth doing in a focused window rather than spread over
weeks — is there a quiet period, or should it wait for the current PR queue to settle?