mirror of
https://github.com/civitai/civitai.git
synced 2026-09-20 22:08:18 +08:00
feat(blocks): compute the per-generation author fee, dark (slice 1 of 3) (#4922)
* feat(blocks): compute the per-generation author fee, dark
The App Blocks author fee is moving from a platform-funded percentage bounty
to an ADDITIVE, AUTHOR-SET, VIEWER-PAID per-generation fee: the viewer pays it
on top of the base generation cost, the platform takes no cut and funds
nothing.
fee = max(flatBuzz, pctOfBase x base_generation_buzz)
This is slice 1 of 3 — the computation and its configuration. It is DARK: it
moves no money, writes no column and reads no row. Settlement onto the
licensing-fee rail is slice 2; the author-facing config UI and the viewer-facing
disclosure are slice 3.
What lands:
- src/server/services/blocks/author-fee.ts — the platform defaults (1 flat /
5% of base), the ceilings (flat <= 100, pct <= 100%, and NO minimum on
either), the per-generation-type table, the pure computation, and the one
gated entry point.
- app-blocks-author-fee-enabled — a dedicated global flag, fail-closed. The
flag is read FIRST, before the base is inspected and before the telemetry
module is imported, so with it off the computation is unreachable and emits
nothing at all. The flag does not exist yet, so the as-merged behaviour is
fully dark.
- Three counters and seven log fields on the path spend attribution already
uses, so the settlement slice can be sized from real traffic before anyone is
charged. Nothing is persisted.
Decisions worth flagging:
- NO MIGRATION. The defaults apply to every app including the 24 that already
exist, so absence of per-app configuration means the default applies and
there is nothing to back-fill. Per-app storage only becomes necessary when an
author can edit it, which is slice 3; a table nobody can write to yet is pure
cost on a database whose migrations are applied by hand per environment.
- ZERO BASE MINTS NOTHING, as a rule separate from the formula. A plain
max(1, 5% x 0) is 1, so the flat leg would invent a fee for a generation that
cost nothing. Zero-base generations are real (17 of 600 measured events).
- THE BASE IS WorkflowCost.base, NOT .total AND NOT the attribution row's
buzzAmount. `total` already carries the per-resource model licensing fees,
the lineage fee and the viewer's tips; the attribution row's buzzAmount is
the realized paid debit, i.e. the same gross. A percentage of either would
take a cut of another creator's licensing fee and would compound as
fee-charging resources stack. All three are plain positive Buzz integers, so
nothing downstream can tell them apart — which is why the base is hoisted off
the raw orchestrator response at each submit path and pinned by a seam test.
- The block-facing snapshot's cost stays `{ total }` only. Surfacing `base`
there would publish the cost breakdown to every third-party app for a number
no app has asked for.
- SELF-SPEND IS CHARGED, unlike attribution (which voids it) and unlike
computeSpendShare (which zeroes the share). A bounty is the platform paying
an author out of platform money, so self-spend is a wash; this fee is the
viewer paying the author, and an author using their own app is a viewer. At
settlement that becomes a transfer from an account to itself, which slice 2
must decide about explicitly rather than discover from a rejected
transaction. Called out at the call site.
- The fee STACKS on top of the lineage fee and the model licensing fee. Slice 1
computes this app's own entry only; it wires nothing into the orchestrator's
fees[] array.
- Deliberately NOT a rate card. A RateCard splits platform revenue with an
author; this is new money flowing to the author with no platform share.
* fix(blocks): seed the author-fee type table, and correct what slice 1 claims
Round-0 audit findings applied. The largest is that the platform per-type
table shipped EMPTY, so every production lookup fell to the default and the
resolver's type/coarse arms were unreachable outside their own unit tests.
- SEED `chat-completion` -> 0 flat / 0 pct. Justin's motivating example as a
PLATFORM default rather than something each author rediscovers in slice 3:
chat completion is the highest-frequency generation type an app runs, so a
1 Buzz flat floor per turn is a per-message toll, not a fee on a generation.
Seeding also makes the resolver live, which changes what is worth logging.
- REPLACE the "not a rate card" argument, which was FALSE as written. It said
a rate card splits platform revenue while this is new money with no platform
share; RATE_CARD_V4's own accounting note refutes that - its spend bounty is
"a SEPARATE platform expense paid ON TOP ... NOT a slice carved out of the
viewer's money", with no three-way conservation invariant. The conclusion
stands for two other reasons, both verified: a RateCard is an immutable,
platform-wide snapshot stamped on a row at write time while this fee is
per-app, author-set and mutable; and every card field is a percent of USD
CENTS, where the card's per-row cent flooring is exactly what made the
bounty pay $0.00 (at spendSharePct 5, computeSpendShare returns 0 cents for
every generation under 200 Buzz).
- DROP the two dynamic imports. The comment claimed the flag is read "before
the telemetry module is even imported"; that was false - buzz-attribution
statically imports ~/server/prom/client and blocks.router statically imports
app-blocks-flag, so both were already in the module cache and the .catch
fallback was unreachable. Static imports now, claim deleted not reworded.
- TRIM the Axiom fields 7 -> 4, one instrument per property. Kept
authorFeeSkipped (the only instrument for the flag-disabled denominator),
authorFeeBuzz and authorFeeBaseBuzz (the counters are labelled coarse_type
only, so the per-app / per-isSelfSpend cut lives here) and
authorFeeParamsSource (no counter carries it, and it is genuinely variable
now the table is seeded). Dropped authorFeeObserved (derivable),
authorFeeLeg (duplicates the observed counter's outcome label) and
authorFeeParamsClamped - re-derived after the seed and still a compile-time
constant false. A ledger test now fails when the set grows OR shrinks.
- REMOVE the redundant .catch at the call site. observeBlockAuthorFee is total
by contract, so it was unreachable - and had it been reachable it would file
a throw into the flag-disabled population, one of the two denominators the
slice-2 sizing read divides by.
- MOVE the seam guard to src/server/services/__tests__/no-divergent-author-fee-base.test.ts.
It is a source-text structural guard over a call-site population, which is
the class the no-*.test.ts convention-guard directory exists for. Outside it,
no-lint-rules-script-drift could not see it and test:lint-rules did not run
it, so the one genuinely red-at-base guard here only ran in the full suite.
test:lint-rules: 42 files / 593 tests -> 43 / 598.
- FIX the guard's self-contradicting header, which said the base must be
snapshot.cost?.base - the opposite of its own next paragraph and assertions.
BlockWorkflowSnapshot.cost is deliberately { total } only, so the base comes
off the raw submit response.
- RECORD that the max combinator is operator-specified verbatim ("make it a
'largest of flat or percent'"). Round 0 flagged it unattributed only because
it had not been told.
Red-before/green-after, measured by restoring byType to [] and swapping in
origin/main's router (both md5-verified back): 8 failed / 43 passed -> 51
passed. Mutation sweep 13 mutants, 12 killed each by the guard owning the
property with its own message, plus a comment-only negative control that
SURVIVED. No guard was unkillable; none deleted.
* fix(blocks): apply round-1 audit findings to the author fee
Round 1 returned no red findings; every item below is a yellow or green.
Three of them were properties the code HAD and nothing asserted — each was
found by a mutant that survived a fully green suite, so each fix is carried
here by that same mutant now dying, not by a test merely being added.
ONE REAL DEFECT — an inert constant with two spellings of one policy.
BLOCK_AUTHOR_FEE_MAX_PCT_OF_BASE had exactly two references: its declaration
and one test asserting toBe(1). No implementation read it.
clampBlockAuthorFeeParams capped the percentage leg with
BLOCK_AUTHOR_FEE_BASIS_POINTS_SCALE, which was doing double duty as scale
factor AND ceiling. Setting the constant to 0.5 and deleting the one pinning
line left every test green — i.e. the "pct <= 100%" policy had two
independent spellings that agreed only by coincidence, and slice 3 reads this
constant for author-input validation, at which point a UI bound and an
enforced bound can silently disagree.
Fixed by DERIVING: BLOCK_AUTHOR_FEE_MAX_PCT_BASIS_POINTS is
Math.round(MAX_PCT_OF_BASE * SCALE), and the clamp enforces that. Guarded by
a test asserting both halves — the RELATIONSHIP (an over-ceiling config must
land at exactly the declared fraction of the base) and the LITERAL (so the
pair cannot drift together). Proven with a mutant that un-derives the bound
and deletes both literal pins, leaving only the relationship to catch it: it
dies, naming the divergence.
THREE UNASSERTED PROPERTIES, each proven by its mutant.
- Self-spend IS charged an author fee. This DIVERGES from attribution two
lines up, where isSelfSpend voids the row and zeroes the share — a bounty is
the platform paying an author out of platform money, while the fee is the
viewer paying the author, and an author using their own app is a viewer. The
divergence was argued in ~15 lines of comment and pinned by nothing: routing
self-spend to a null base survived green. Now asserted end-to-end on the log
line (voided row, isSelfSpend true, fee 32 off a 640 base).
- The fee is observed AFTER the successful write, never before. That ordering
is the only thing stopping a P2002 re-poll from double-counting, and the
sizing number is this slice's entire purpose. Hoisting the observation above
the create survived green. Now asserted via the flag read — the one thing
every path through observeBlockAuthorFee does — as "a duplicate emits no
second observation".
- The flag-off docblock overstated its test. The comment claimed "a gate moved
below the computation would show up right here"; it does not, because
computeBlockAuthorFee is pure and a hoisted copy emits no counter either.
WIDENED rather than narrowed: a new test hands observeBlockAuthorFee an args
object whose generationType and config are GETTERS, and asserts neither is
read with the flag off — any invocation of the computation must read its
inputs, purity or not. The same test carries its own positive control (flag
ON reads both). The old comment is corrected to say exactly what the counter
assertions do and do not pin. Harmless in slice 1; load-bearing in slice 2,
where the gated code moves money.
SMALLER ITEMS.
- The comment justifying the removed .catch called the enclosing catch "loud",
which understates it by three effects: a rejection there also skips the
success Axiom line for a row that WAS written, skips the write counter, and
runs refundAppBountyAccrual against a persisted row (inert only while
appOwnerShareCents is identically 0). The unreachability argument is sound
for today's caller, so the comment is corrected rather than the .catch
reinstated — and it now records that a reinstated catch needs a THIRD skip
reason, never flag-disabled and never base-unavailable, both being live
denominators.
- The test comment claiming "the dynamic imports inside observeBlockAuthorFee
resolve to them" is corrected: both imports are static, and vi.mock hoisting
is what makes the mocks apply. The same claim was already deleted from the
source docblock.
- The basis-point quantization now FLOORS instead of rounding.
Math.round(0.049999 * 10_000) is 500 bp, i.e. a full 5%, against the module's
own promise that "a stated 5% never charges more than 5%". Unreachable in
slice 1 (only 0.05 and 0 exist), reachable the moment authors type numbers.
A naive Math.floor is NOT the fix on its own: measured, it loses a basis
point on 573 of the 10,001 exact basis-point inputs, because 0.0029 * 10_000
lands at 28.999999999999996 and an author typing 0.29% would be charged
0.28%. Normalising to 6 decimal places first makes all 10,001 exact (pinned
by an exhaustive test) while still flooring 0.049999 to 499.
- One spelling of the missing-base skip. The Prometheus outcome label said
base_unavailable while the Axiom field said base-unavailable — two spellings
of one concept across the two instruments a sizing read has to join. Both now
come from one exported constant.
MUTATION MATRIX (both covering suites, 90 tests, at this commit):
M14 self-spend routed to a null base SURVIVED -> KILLED
M15 observation hoisted above the write SURVIVED -> KILLED
M4 computation invoked before the gate SURVIVED -> KILLED
M1 ceiling moved, its one pin deleted SURVIVED -> KILLED
M1' + the relationship assertion alone SURVIVED -> KILLED
M13 comment-only (NEGATIVE CONTROL) SURVIVED (as required)
harness positive control (default flat 1->2) KILLED, 7 tests
Each kill was read from the runner's own per-test counts, not an exit code,
and each killing assertion carries its own message naming the property.
No behaviour change at any reachable slice-1 input: the derived ceiling is
10_000 exactly as before, and the only percentages production can produce are
0.05 and 0.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(blocks): apply round-2 audit findings to the author fee
Round 2 verified all eleven prior claims and returned no red findings. Three of
the four items here are false sentences in shipped comments — the class this PR
has been fixing for two rounds — and one is a latent code issue.
F1 — THE PR SAID THE FLIPT FLAG DOES NOT EXIST. IT NOW DOES.
app-blocks-author-fee-enabled was created in the flag store at enabled: false
after this branch's last commit, deliberately: round 1 found that an ABSENT key
makes the evaluation throw, bypass its cache and write a console.error on every
App Blocks generation submit, indefinitely.
Verified live before rewriting, rather than taken on trust — in the civitai-app
environment the flag is BOOLEAN_FLAG_TYPE, enabled: false, with empty variants /
rules / rollouts, and a global boolean evaluation returns
enabled:false, reason:DEFAULT_EVALUATION_REASON, segmentKeys:[]. Negative
control: a fabricated key 404s.
Four sites corrected, not three. The brief named author-fee.ts, app-blocks-flag.ts
and the PR body; a fourth operator note in author-fee.ts still read "create
app-blocks-author-fee-enabled", which is the same stale claim in imperative form.
The conclusion survives — as-merged behaviour is dark — but the REASON changes
and the safety property is genuinely weaker than the old wording claimed. An
absent key had to be CREATED by an operator before anyone could enable the fee;
a present base-false flag is one toggle away, with no deploy and no review. So
the comments now say the posture is flag STATE, not structure, and "cannot
regress open" is withdrawn rather than reworded.
F2 — THE LABEL UNIFICATION WAS ONE SITE SHORT, AT THE METRIC'S DECLARATION.
packages/civitai-telemetry/src/client.ts still enumerated base_unavailable
(underscore) while the emitted value is base-unavailable (hyphen). That comment
is the ONLY place in the repo enumerating the label's value set and it sits
directly above blockAuthorFeeObservedCounter, where an operator writing the
slice-2 sizing join looks: they would query outcome="base_unavailable", get an
empty series, and read it as "no generation lacked a base" — exactly the silent
empty join the unification exists to prevent.
The two other base_unavailable occurrences (author-fee.ts and the test) are
historical narration of the form "it used to say X here and Y there" and are
deliberately left alone.
F4 — THE CEILING CONSTANT ROUNDED THE OPPOSITE WAY TO THE QUANTIZATION IT GOVERNS.
BLOCK_AUTHOR_FEE_MAX_PCT_BASIS_POINTS derived with Math.round while
toBasisPoints — added in the same commit, a few lines above — deliberately
floors, because in the module's own words "every rounding goes toward the
viewer". Inert today (MAX_PCT_OF_BASE = 1 gives 10000 either way, and a guard
pins it) and live the moment slice 3 or a policy change sets a non-basis-point
ceiling: 0.123456 derives to 1235 bp = 12.35%, a ceiling ABOVE the declared
policy, the one direction the module says it never rounds.
The derivation is now blockAuthorFeeCeilingBasisPoints(maxPctOfBase), which
calls toBasisPoints. It is a named function rather than an expression inlined
into the constant for a specific reason, recorded at the site: at a declared
ceiling of 1 no assertion about the constant can tell floor from round, so
inlining it makes the defect unkillable rather than merely dormant. A comment on
toBasisPoints now names both callers, so changing its direction is visibly a
change to the enforced ceiling too.
Carried by a mutant, not by a test merely being added: reverting the derivation
to Math.round — which is the pre-change code on the same input — fails exactly
one test, with its own message, "the ceiling derivation ROUNDS - it must floor,
like the quantization it governs: expected 1235 to be 1234". The guard also pins
1235 as the mutant's answer so the case cannot quietly stop discriminating, and
asserts the property at four ceilings that each round up.
F3 — A CONTROL MESSAGE NAMED THE WRONG CAUSE ON AN OVER-READ.
The positive control asserted toBe(1) under the message "probe wired to nothing
— flag ON read nothing", which is right only for 0. Round 2 demonstrated it: an
innocuous second read past the gate produced "probe wired to nothing - flag ON
read nothing: expected 2 to be 1", sending a maintainer into the probe when the
real change is downstream of the gate. Split into a toBeGreaterThan(0) control —
the claim the zeros above actually need — and a separate exactness pin naming
the other direction. Verified by planting a second args.generationType read past
the gate: it now fails with "generationType was read more than once past the
gate - a change downstream of the flag, not a broken probe: expected 2 to be 1".
TESTING
Two covering suites: 90 passed (90) at 3a81dc7 -> 91 passed (91) here, one new
test. With the seam guard: 96 passed (96) across 3 files. The telemetry package
suite is 38 passed (38). Prettier clean on all four files, validated against a
deliberately misformatted copy that it correctly rejected.
Mutation sweep, each mutant restored and byte-compared with cmp afterwards:
M1 platform table emptied KILLED 6 failed / 85 passed
M1' ceiling un-derived, pins deleted KILLED 2 failed / 89 passed
M4 resolver exact-match arm deleted KILLED 6 failed / 85 passed
M5 dark gate deleted KILLED 3 failed / 88 passed
M14 label unification reverted KILLED 2 failed / 89 passed
M15 counter outcome label desynced KILLED 1 failed / 90 passed
F4 ceiling reverted to Math.round KILLED 1 failed / 90 passed
M13 negative control, comment-only SURVIVED 91 passed
NC2 negative control, comment-only SURVIVED 91 passed
Two negative controls rather than one, in two different files, so a green sweep
is not a claim about a runner wired to always-kill.
* fix(blocks): wire the fourth submit path, fee-free while its price is a cap
A fourth `recordSpendAttribution` call site landed on `main` mid-review —
`submitPassThroughStepWorkflow` — passing no base. The seam guard caught it and
went red, which is the guard doing its job: nothing in this branch's own commits
broke.
The new site is WIRED rather than exempted (an exemption is what the ledger
exists to make impossible), but it does NOT charge a fee while its price is a
CAP. `WorkflowCost.variable` means the quoted price is a ceiling that settles
lower: the viewer is charged the maximum up front and refunded the difference
once the provider reports the work delivered. A percentage of that is a fee on
money they did not ultimately spend. That path quotes a ceiling and refunds
`ceiling - actual` on settle, so it is the motivating case.
The cap flag is hoisted off the raw orchestrator response and threaded from ALL
FOUR submit paths, not just the new one — the predicate lives in one place
(`observeBlockAuthorFee`), and a cap-priced generation on any path is the same
question.
A cap gets its OWN counted skip reason, `price-is-cap`, checked BEFORE the base
check. It is deliberately not `base-unavailable`: that is the RECOVERABLE blind
spot, one of the two denominators the slice-2 sizing read divides by, and a
cap-priced generation would not have charged even with a base in hand. The value
is enumerated in `packages/civitai-telemetry/src/client.ts`, the only place in
the repo that enumerates that label's value set, in the same hyphenated spelling
the Axiom `authorFeeSkipped` field uses so the two instruments still join.
"No fee on a cap" is the CURRENT answer, made explicit and testable, not settled
policy — slice 2 owns what a cap-priced path should charge, and now has a counted
population to decide it against instead of an unlabelled blind spot.
Seam guard: ledger 3 -> 4, plus a second relationship over the same population
(every call site must thread the cap flag, and it must come from
`submitted.cost.variable`, never from `snapshot` — whose `cost` is `{ total }`
only, so reading one would be `undefined`, i.e. the fail-OPEN direction).
* fix(blocks): apply round-3 audit findings to the author fee
Three green findings from the final audit round.
1. The ceiling constant could be re-inlined undetected. Replacing the
initialiser of BLOCK_AUTHOR_FEE_MAX_PCT_BASIS_POINTS with
Math.round(MAX_PCT_OF_BASE * SCALE) — leaving the named derivation
function in place — SURVIVED the whole suite, because every behavioural
pin agrees while floor and round agree at the shipped policy of 1. The
"DO NOT INLINE IT BACK" comment was the only guard, and prose is not a
guard. Adds a source-text assertion, the same technique the router seam
guard already uses: the constant's initialiser must name
blockAuthorFeeCeilingBasisPoints and must perform no arithmetic of its
own. That mutant now goes red with its own message.
2. The .catch comment in buzz-attribution.service went stale. It said a
reinstated catch would need "a THIRD skip reason … never flag-disabled
and never base-unavailable"; price-is-cap has since landed, so it would
be the FOURTH and the "never" list had a hole exactly where the newest
reason sat. Restated against BlockAuthorFeeSkipReason as a whole, so the
next addition cannot re-stale it.
3. An absent WorkflowCost.variable is treated as a final price, and that is
the fail-open direction — undocumented until now. The field is
?: null | boolean, so === true merges "not a cap" with "the orchestrator
did not say", and a field that stops being sent silently resumes charging
on cap-priced generations. Documented at the txt2img hoist, at the
pass-through hoist, and in the seam guard, which pins that the flag comes
off the right OBJECT and says nothing about it being absent. Behaviour
unchanged: the tri-state policy is slice 2's to decide.
---------
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -178,9 +178,9 @@ Worked examples of both fixes: the two retry tests in
|
||||
|
||||
### Convention guards
|
||||
|
||||
37 live in `src/server/services/__tests__/no-*.test.ts`:
|
||||
38 live in `src/server/services/__tests__/no-*.test.ts`:
|
||||
`no-agent-ground-truth-write`, `no-coerce-boolean-in-api`, `no-direct-shared-module-mock`,
|
||||
`no-divergent-can-generate-derivation`, `no-divergent-generation-submit-payload` (the two generation footers must submit the same payload keys — the form-graph lane silently dropped `sourceProvenance`, so its remixes lost the only VERIFIED half of their provenance while the unverified `remixOfId` went through), `no-divergent-model-recency-derivation` (the New/Updated card rule and its day-old cutoff each have one definition — three cards restated them, and when the paid badge took ModelCard's single status slot only that copy knew, so a paid model published minutes ago showed "Paid" on the feed and "New" in the resource picker), `no-divergent-paid-gate-derivation` (the feed and the search index must derive the paid badge from one helper, never two copies of the query), `no-divergent-safetensor-rule` (the coverage view and `checkLoadable` state the checkpoint SafeTensor rule twice and nothing executes the SQL, so the two literals and the checkpoint scoping are pinned textually), `no-doubled-free-slot-noun`, `no-hand-typed-redis-key-constants` (the Redis key-constant
|
||||
`no-divergent-author-fee-base` (every `recordSpendAttribution` call site must pass the App Blocks author fee the orchestrator's `submitted.cost.base`, never the snapshot and never the gross `buzzAmount`), `no-divergent-can-generate-derivation`, `no-divergent-generation-submit-payload` (the two generation footers must submit the same payload keys — the form-graph lane silently dropped `sourceProvenance`, so its remixes lost the only VERIFIED half of their provenance while the unverified `remixOfId` went through), `no-divergent-model-recency-derivation` (the New/Updated card rule and its day-old cutoff each have one definition — three cards restated them, and when the paid badge took ModelCard's single status slot only that copy knew, so a paid model published minutes ago showed "Paid" on the feed and "New" in the resource picker), `no-divergent-paid-gate-derivation` (the feed and the search index must derive the paid badge from one helper, never two copies of the query), `no-divergent-safetensor-rule` (the coverage view and `checkLoadable` state the checkpoint SafeTensor rule twice and nothing executes the SQL, so the two literals and the checkpoint scoping are pinned textually), `no-doubled-free-slot-noun`, `no-hand-typed-redis-key-constants` (the Redis key-constant
|
||||
ratchet — hand-typed `REDIS_KEYS` in an allowlisted mock had drifted 15 times), `no-io-in-transaction`,
|
||||
`no-job-kind-on-remix-mint`, `no-lint-rules-script-drift`,
|
||||
`no-menu-target-tooltip-nesting` (a `Tooltip` INSIDE `Menu.Target` steals the ref the menu needs and
|
||||
@@ -208,7 +208,7 @@ fail only in a full-suite run. Five were missing when this was last audited, on
|
||||
wired in then. If the diff adds a guard, check it was wired into the script, and don't treat a green
|
||||
`test:lint-rules` as "all guards passed".
|
||||
|
||||
`test:lint-rules` names 42 files today.
|
||||
`test:lint-rules` names 43 files today.
|
||||
|
||||
Both numbers and the list are checked by `no-lint-rules-script-drift`, which reads the two phrasings
|
||||
above literally — edit the numbers, not the shapes.
|
||||
|
||||
@@ -211,8 +211,9 @@ Use a top-level `import type * as PromClient` — an inline `typeof import('...'
|
||||
**Before widening a mock, check whether the import edge is needed at all.** A failing suite may be telling you the code pulled in a dependency it doesn't want, not that the mock is too narrow, and widening it would hide that. (Bit us twice in one day, Aug 2026, on two branches; one of those three suites was fixed by extracting the helpers into their own module instead.)
|
||||
|
||||
#### Convention guards run as tests
|
||||
Several repo conventions are enforced by tests, not by eslint. 37 live in `src/server/services/__tests__/no-*.test.ts` — `no-agent-ground-truth-write`, `no-coerce-boolean-in-api`,
|
||||
Several repo conventions are enforced by tests, not by eslint. 38 live in `src/server/services/__tests__/no-*.test.ts` — `no-agent-ground-truth-write`, `no-coerce-boolean-in-api`,
|
||||
`no-direct-shared-module-mock` (the shared-mock ratchet, see `docs/testing/shared-module-mocks.md`),
|
||||
`no-divergent-author-fee-base` (every `recordSpendAttribution` call site must pass the App Blocks author fee the orchestrator's `submitted.cost.base`, never the snapshot and never the gross `buzzAmount` — the three are indistinguishable positive Buzz integers, so a percentage of the wrong one takes a cut of another creator's licensing fee),
|
||||
`no-divergent-can-generate-derivation` (coverage alone is not canGenerate — the ecosystem must also support the model TYPE, and the pair is composed only in `isGenerationEligible`),
|
||||
`no-divergent-generation-submit-payload` (the two generation footers must submit the same payload keys — the form-graph lane silently dropped `sourceProvenance`, so its remixes lost the only VERIFIED half of their provenance while the unverified `remixOfId` went through), `no-divergent-model-recency-derivation` (the New/Updated card rule and its day-old cutoff each have one definition — three cards restated them, and when the paid badge took ModelCard's single status slot only that copy knew, so a paid model published minutes ago showed "Paid" on the feed and "New" in the resource picker), `no-divergent-paid-gate-derivation` (the feed and the search index must derive the paid badge from one helper, never two copies of the query), `no-divergent-safetensor-rule` (the coverage view and `checkLoadable` state the checkpoint SafeTensor rule twice and nothing executes the SQL, so the two literals and the checkpoint scoping are pinned textually), `no-doubled-free-slot-noun`, `no-hand-typed-redis-key-constants` (the Redis key-constant
|
||||
ratchet — hand-typed `REDIS_KEYS` in an allowlisted mock had drifted 15 times), `no-io-in-transaction`,
|
||||
@@ -253,7 +254,7 @@ was last audited, on 2026-08-24, and were wired in then. **Add a new guard to th
|
||||
you write it**, and don't read a green `test:lint-rules` as "all guards passed" without checking the directory
|
||||
against the script.
|
||||
|
||||
`test:lint-rules` names 42 files today.
|
||||
`test:lint-rules` names 43 files today.
|
||||
|
||||
The count above, the count in the list, and the list itself are what went stale three times, so
|
||||
`no-lint-rules-script-drift` fails when they disagree with the directory or the script. It reads two exact
|
||||
|
||||
+1
-1
@@ -107,7 +107,7 @@
|
||||
"test:packages:run": "vitest run --project '@civitai/*'",
|
||||
"test:apps": "vitest --project 'app:*'",
|
||||
"test:apps:run": "vitest run --project 'app:*'",
|
||||
"test:lint-rules": "vitest run --project 'unit*' src/server/notifications/__tests__/notification-settings-polarity.test.ts src/server/schema/__tests__/track.addView.schema.test.ts src/server/services/__tests__/hub-filter-parity.test.ts src/server/services/__tests__/poi-checks-strip-benign-phrases.test.ts src/server/services/__tests__/video-leaderboard-badge-staging.test.ts src/server/services/__tests__/no-agent-ground-truth-write.test.ts src/server/services/__tests__/no-coerce-boolean-in-api.test.ts src/server/services/__tests__/no-direct-shared-module-mock.test.ts src/server/services/__tests__/no-divergent-can-generate-derivation.test.ts src/server/services/__tests__/no-divergent-model-recency-derivation.test.ts src/server/services/__tests__/no-divergent-generation-submit-payload.test.ts src/server/services/__tests__/no-divergent-paid-gate-derivation.test.ts src/server/services/__tests__/no-divergent-safetensor-rule.test.ts src/server/services/__tests__/no-doubled-free-slot-noun.test.ts src/server/services/__tests__/no-hand-typed-redis-key-constants.test.ts src/server/services/__tests__/no-io-in-transaction.test.ts src/server/services/__tests__/no-job-kind-on-remix-mint.test.ts src/server/services/__tests__/no-lint-rules-script-drift.test.ts src/server/services/__tests__/no-menu-target-tooltip-nesting.test.ts src/server/services/__tests__/no-module-scope-cache.test.ts src/server/services/__tests__/no-pk-addressed-engagement-write.test.ts src/server/services/__tests__/no-server-infra-in-app-graph.test.ts src/server/services/__tests__/no-sharp-outside-native-project.test.ts src/server/services/__tests__/no-ssr-divergent-media-query.test.ts src/server/services/__tests__/no-stale-moderator-route-probe.test.ts src/server/services/__tests__/no-static-html2canvas-import.test.ts src/server/services/__tests__/no-unbounded-paging-fake.test.ts src/server/services/__tests__/no-unbumped-draft-status-write.test.ts src/server/services/__tests__/no-unguarded-billable-submit.test.ts src/server/services/__tests__/no-unguarded-block-bridge-token.test.ts src/server/services/__tests__/no-unguarded-block-rest-token.test.ts src/server/services/__tests__/no-unguarded-user-text.test.ts src/server/services/__tests__/no-unloadable-image-fixture.test.ts src/server/services/__tests__/no-unmoderated-blob-retraction.test.ts src/server/services/__tests__/no-unmuteable-comment-processor.test.ts src/server/services/__tests__/no-unpriced-default-model.test.ts src/server/services/__tests__/no-unroled-image-resource-match.test.ts src/server/services/__tests__/no-unscoped-email-verification-exemption.test.ts src/server/services/__tests__/no-untruthy-query-gate.test.ts src/server/services/__tests__/no-unverified-provenance-write.test.ts src/server/services/__tests__/no-unwrapped-knob-rotation.test.ts src/server/services/__tests__/no-wholesale-module-mock.test.ts",
|
||||
"test:lint-rules": "vitest run --project 'unit*' src/server/notifications/__tests__/notification-settings-polarity.test.ts src/server/schema/__tests__/track.addView.schema.test.ts src/server/services/__tests__/hub-filter-parity.test.ts src/server/services/__tests__/poi-checks-strip-benign-phrases.test.ts src/server/services/__tests__/video-leaderboard-badge-staging.test.ts src/server/services/__tests__/no-agent-ground-truth-write.test.ts src/server/services/__tests__/no-coerce-boolean-in-api.test.ts src/server/services/__tests__/no-direct-shared-module-mock.test.ts src/server/services/__tests__/no-divergent-author-fee-base.test.ts src/server/services/__tests__/no-divergent-can-generate-derivation.test.ts src/server/services/__tests__/no-divergent-model-recency-derivation.test.ts src/server/services/__tests__/no-divergent-generation-submit-payload.test.ts src/server/services/__tests__/no-divergent-paid-gate-derivation.test.ts src/server/services/__tests__/no-divergent-safetensor-rule.test.ts src/server/services/__tests__/no-doubled-free-slot-noun.test.ts src/server/services/__tests__/no-hand-typed-redis-key-constants.test.ts src/server/services/__tests__/no-io-in-transaction.test.ts src/server/services/__tests__/no-job-kind-on-remix-mint.test.ts src/server/services/__tests__/no-lint-rules-script-drift.test.ts src/server/services/__tests__/no-menu-target-tooltip-nesting.test.ts src/server/services/__tests__/no-module-scope-cache.test.ts src/server/services/__tests__/no-pk-addressed-engagement-write.test.ts src/server/services/__tests__/no-server-infra-in-app-graph.test.ts src/server/services/__tests__/no-sharp-outside-native-project.test.ts src/server/services/__tests__/no-ssr-divergent-media-query.test.ts src/server/services/__tests__/no-stale-moderator-route-probe.test.ts src/server/services/__tests__/no-static-html2canvas-import.test.ts src/server/services/__tests__/no-unbounded-paging-fake.test.ts src/server/services/__tests__/no-unbumped-draft-status-write.test.ts src/server/services/__tests__/no-unguarded-billable-submit.test.ts src/server/services/__tests__/no-unguarded-block-bridge-token.test.ts src/server/services/__tests__/no-unguarded-block-rest-token.test.ts src/server/services/__tests__/no-unguarded-user-text.test.ts src/server/services/__tests__/no-unloadable-image-fixture.test.ts src/server/services/__tests__/no-unmoderated-blob-retraction.test.ts src/server/services/__tests__/no-unmuteable-comment-processor.test.ts src/server/services/__tests__/no-unpriced-default-model.test.ts src/server/services/__tests__/no-unroled-image-resource-match.test.ts src/server/services/__tests__/no-unscoped-email-verification-exemption.test.ts src/server/services/__tests__/no-untruthy-query-gate.test.ts src/server/services/__tests__/no-unverified-provenance-write.test.ts src/server/services/__tests__/no-unwrapped-knob-rotation.test.ts src/server/services/__tests__/no-wholesale-module-mock.test.ts",
|
||||
"test:component": "node scripts/test-component-run.mjs",
|
||||
"test:component:watch": "vitest --project component",
|
||||
"test:geometry": "vitest run --project geometry",
|
||||
|
||||
@@ -313,6 +313,60 @@ export const blockSpendAttributionWriteCounter = registerCounterWithLabels({
|
||||
labelNames: ['status'] as const,
|
||||
});
|
||||
|
||||
// App Blocks PER-GENERATION AUTHOR FEE — DARK. These three count what the fee
|
||||
// WOULD be; slice 1 charges nobody and stores nothing, so this counter trio is
|
||||
// the only record that the computation ran, and the only way to size the
|
||||
// settlement slice from real traffic before anyone is billed.
|
||||
//
|
||||
// `coarse_type` is the COARSE generation key (`blockGenerationCoarseType`) or
|
||||
// the literal `unknown` — bounded by the step/recipe registries, so the label is
|
||||
// low-cardinality by construction and cannot be widened by traffic.
|
||||
// `outcome` is which leg governed (`flat` / `pct` / `none`), or one of two
|
||||
// counted SKIPS:
|
||||
// `base-unavailable` the orchestrator surfaced no `WorkflowCost.base` to
|
||||
// compute against — the RECOVERABLE blind spot.
|
||||
// `price-is-cap` `WorkflowCost.variable` was true, i.e. the price is a
|
||||
// CAP that may settle lower (a post-billed step charged
|
||||
// up front at its maximum and refunded down). No fee is
|
||||
// computed on one: a percentage of a number the viewer is
|
||||
// partly refunded is a fee on money they did not spend.
|
||||
// Kept SEPARATE from `base-unavailable` on purpose — that
|
||||
// one is a denominator the slice-2 sizing read divides by,
|
||||
// and a cap-priced generation would not have charged even
|
||||
// with a base in hand.
|
||||
// The `flag-disabled` skip is NOT here: it emits no counter at all by design and
|
||||
// is visible only as the Axiom `authorFeeSkipped` field.
|
||||
// 🔴 HYPHEN, not underscore, and this is the ONLY place in the repo that
|
||||
// enumerates the value set — so it is what an operator writing the slice-2
|
||||
// sizing join reads. Each skip is deliberately the SAME string as the Axiom
|
||||
// `authorFeeSkipped` field (`BLOCK_AUTHOR_FEE_BASE_UNAVAILABLE` /
|
||||
// `BLOCK_AUTHOR_FEE_PRICE_IS_CAP` in
|
||||
// `~/server/services/blocks/author-fee`), because that join is the whole point:
|
||||
// querying `outcome="base_unavailable"` returns an empty series, which reads as
|
||||
// "no generation lacked a base" rather than "you spelled the label wrong".
|
||||
export const blockAuthorFeeObservedCounter = registerCounterWithLabels({
|
||||
name: 'block_author_fee_observed_total',
|
||||
help: 'App Blocks per-generation author-fee computations observed (dark — no money moves), by coarse generation type and governing leg',
|
||||
labelNames: ['coarse_type', 'outcome'] as const,
|
||||
});
|
||||
|
||||
// Sum of the fee that WOULD have been charged. Divide by the base counter below
|
||||
// for the realized effective rate per coarse type; on its own it is the Buzz
|
||||
// volume slice 2 would have to settle.
|
||||
export const blockAuthorFeeBuzzCounter = registerCounterWithLabels({
|
||||
name: 'block_author_fee_buzz_total',
|
||||
help: 'Buzz the App Blocks per-generation author fee would have charged (dark), by coarse generation type',
|
||||
labelNames: ['coarse_type'] as const,
|
||||
});
|
||||
|
||||
// The denominator: sum of `WorkflowCost.base` the fee was computed against.
|
||||
// NOT the workflow total — that already carries licensing fees and tips.
|
||||
export const blockAuthorFeeBaseBuzzCounter = registerCounterWithLabels({
|
||||
name: 'block_author_fee_base_buzz_total',
|
||||
help: 'Base generation Buzz the App Blocks author fee was computed against, by coarse generation type',
|
||||
labelNames: ['coarse_type'] as const,
|
||||
});
|
||||
|
||||
// App Blocks MEMBERSHIP / subscription attribution (one row per paid invoice of a
|
||||
// block-initiated membership purchase).
|
||||
export const blockSubscriptionAttributionWriteCounter = registerCounterWithLabels({
|
||||
|
||||
@@ -5750,6 +5750,36 @@ export const blocksRouter = router({
|
||||
// (which runs AFTER the try/catch) can read the REALIZED per-account
|
||||
// debit — `submitted` is a try-block `const` and is out of scope there.
|
||||
let realizedTransactions: Awaited<ReturnType<typeof submitWorkflow>>['transactions'];
|
||||
// Hoisted for the same reason as `realizedTransactions` above: the DARK
|
||||
// per-generation author-fee observation needs the orchestrator's BASE cost,
|
||||
// and it is NOT reachable from `snapshot`. 🔴 `BlockWorkflowSnapshot.cost` is
|
||||
// deliberately `{ total }` ONLY — that is the block-facing WIRE shape, and
|
||||
// widening it would publish the platform's cost breakdown to every
|
||||
// third-party app for a number no app has asked for. So the base is read off
|
||||
// the raw orchestrator response here and passed to the attribution writer,
|
||||
// never surfaced to the block.
|
||||
let realizedBaseCost: number | null = null;
|
||||
// `WorkflowCost.variable` — TRUE when the price is a CAP that may settle
|
||||
// lower. Hoisted and threaded for the SAME reason as the base: nothing
|
||||
// downstream of this response can tell a cap apart from a final price, and
|
||||
// the author fee must not be computed on one. See
|
||||
// `BLOCK_AUTHOR_FEE_PRICE_IS_CAP`.
|
||||
//
|
||||
// 🔴 AN ABSENT FIELD IS TREATED AS A FINAL PRICE, AND THAT IS THE
|
||||
// FAIL-OPEN DIRECTION. `WorkflowCost.variable` is `?: null | boolean`, so
|
||||
// `=== true` collapses a genuine tri-state — cap / not-a-cap / the
|
||||
// orchestrator did not say — into two, and the two it merges are
|
||||
// "not-a-cap" and "unknown". An orchestrator that stops sending the field
|
||||
// therefore silently resumes charging on every cap-priced generation
|
||||
// instead of skipping them; nothing errors and no skip counter moves.
|
||||
// Chosen deliberately over `!== false`, which fails the other way and
|
||||
// would suppress the fee on every path the moment the field went missing.
|
||||
// 🔴 INERT IN SLICE 1 — this observation moves no money, so today the
|
||||
// consequence is only a biased sizing read. It becomes a MONEY question
|
||||
// the moment slice 2 settles, and the tri-state policy (skip on unknown,
|
||||
// charge on unknown, or require the field) is SLICE 2'S TO DECIDE, not
|
||||
// this slice's. Do not quietly pick one here.
|
||||
let realizedPriceIsCap: boolean | null = null;
|
||||
try {
|
||||
// Daily-boost autoclaim. Cost cleared the install's budget cap; check
|
||||
// whether the user's actual spendable Buzz can pay for it. If they're
|
||||
@@ -5802,6 +5832,8 @@ export const blocksRouter = router({
|
||||
// validation inside `createBlockTextToImageStep` above.
|
||||
snapshot = snapshotFromWorkflow(submitted, { modelSubstitutions });
|
||||
realizedTransactions = submitted.transactions;
|
||||
realizedBaseCost = typeof submitted.cost?.base === 'number' ? submitted.cost.base : null;
|
||||
realizedPriceIsCap = submitted.cost?.variable === true;
|
||||
} catch (e) {
|
||||
// No resolved submit → undo the reservation (net-equivalent to the old
|
||||
// "only record after a resolved submit" behavior) and propagate. Refund
|
||||
@@ -6080,6 +6112,18 @@ export const blocksRouter = router({
|
||||
generationType: resolveBlockGenerationType(textToImageBody, {
|
||||
imageWorkflowType: generateInput.workflow,
|
||||
}),
|
||||
// BASE generation cost, for the DARK per-generation author-fee
|
||||
// observation only (never persisted). 🔴 `.base`, NOT `.total` and
|
||||
// NOT `buzzAmount` above: `total` already carries the per-resource
|
||||
// model licensing fees, the lineage fee and the viewer's tips, and
|
||||
// the author fee is additive ON TOP of the base and stacks alongside
|
||||
// those. Absent → the observation records a skip rather than
|
||||
// computing against a number that means something else.
|
||||
baseGenerationBuzz: realizedBaseCost,
|
||||
// …and whether that price is a CAP. True → no fee is computed, under
|
||||
// its own skip reason. Threaded from every submit path so the rule
|
||||
// lives in ONE place.
|
||||
generationPriceIsCap: realizedPriceIsCap,
|
||||
});
|
||||
})().catch(() => {
|
||||
/* best-effort: a failed attribution write never breaks submit */
|
||||
@@ -8033,6 +8077,13 @@ async function submitCustomComfyWorkflow(opts: {
|
||||
// `submitted` is a try-block `const` and is out of scope there. Mirrors the
|
||||
// txt2img path (~:3646).
|
||||
let realizedTransactions: Awaited<ReturnType<typeof submitWorkflow>>['transactions'];
|
||||
// The orchestrator's BASE cost, for the DARK author-fee observation. Hoisted
|
||||
// for the same reason, and read off the raw response rather than `snapshot`,
|
||||
// for the reason spelled out on the txt2img path.
|
||||
let realizedBaseCost: number | null = null;
|
||||
// `WorkflowCost.variable` — TRUE when the price is a CAP that may settle lower;
|
||||
// the author fee is skipped on one. Same hoist, same reason.
|
||||
let realizedPriceIsCap: boolean | null = null;
|
||||
// Captured just before the orchestrator submit → the server-side proxy for the
|
||||
// job's submit instant, so the settle-time wall-clock metric measures
|
||||
// submit→terminal-observation (incl. GPU queue-wait). Observability-only.
|
||||
@@ -8077,6 +8128,8 @@ async function submitCustomComfyWorkflow(opts: {
|
||||
});
|
||||
snapshot = snapshotFromWorkflow(submitted);
|
||||
realizedTransactions = submitted.transactions;
|
||||
realizedBaseCost = typeof submitted.cost?.base === 'number' ? submitted.cost.base : null;
|
||||
realizedPriceIsCap = submitted.cost?.variable === true;
|
||||
} catch (e) {
|
||||
await refundBlockBuzzReservation(reservation, ceiling);
|
||||
if (appSpendReserve) {
|
||||
@@ -8245,6 +8298,13 @@ async function submitCustomComfyWorkflow(opts: {
|
||||
// parsed. Non-throwing: an unresolvable sub-axis degrades to the bare
|
||||
// `customComfy` key, an unresolvable body to NULL.
|
||||
generationType: resolveBlockGenerationType(body),
|
||||
// BASE generation cost for the DARK author-fee observation — `.base`,
|
||||
// never `.total` (which already carries licensing fees and tips) and
|
||||
// never `buzzAmount`. Same rule as the txt2img path.
|
||||
baseGenerationBuzz: realizedBaseCost,
|
||||
// …and whether that price is a CAP. True → no fee is computed, under its
|
||||
// own skip reason. Same rule, same single place, as the txt2img path.
|
||||
generationPriceIsCap: realizedPriceIsCap,
|
||||
});
|
||||
})().catch(() => {
|
||||
/* best-effort: a failed attribution write never breaks submit */
|
||||
@@ -9142,6 +9202,13 @@ async function submitStepWorkflow(opts: {
|
||||
// Hoisted out of the try so the post-submit spend-attribution closure can read
|
||||
// the REALIZED per-account debit.
|
||||
let realizedTransactions: Awaited<ReturnType<typeof submitWorkflow>>['transactions'];
|
||||
// The orchestrator's BASE cost, for the DARK author-fee observation. Hoisted
|
||||
// for the same reason, and read off the raw response rather than `snapshot`,
|
||||
// for the reason spelled out on the txt2img path.
|
||||
let realizedBaseCost: number | null = null;
|
||||
// `WorkflowCost.variable` — TRUE when the price is a CAP that may settle lower;
|
||||
// the author fee is skipped on one. Same hoist, same reason.
|
||||
let realizedPriceIsCap: boolean | null = null;
|
||||
const submittedAt = Date.now();
|
||||
try {
|
||||
// `orchestratorStep` + `tags` were built above the quote — the SAME objects
|
||||
@@ -9161,6 +9228,8 @@ async function submitStepWorkflow(opts: {
|
||||
});
|
||||
snapshot = snapshotFromWorkflow(submitted);
|
||||
realizedTransactions = submitted.transactions;
|
||||
realizedBaseCost = typeof submitted.cost?.base === 'number' ? submitted.cost.base : null;
|
||||
realizedPriceIsCap = submitted.cost?.variable === true;
|
||||
} catch (e) {
|
||||
await refundBlockBuzzReservation(reservation, reserveBuzz);
|
||||
if (appSpendReserve) {
|
||||
@@ -9474,6 +9543,13 @@ async function submitStepWorkflow(opts: {
|
||||
// step invocation row's `detail`. `isBlockGenerationType` refuses
|
||||
// `convert-image:<anything>` for exactly that reason.
|
||||
generationType: resolveBlockGenerationType(body),
|
||||
// BASE generation cost for the DARK author-fee observation — `.base`,
|
||||
// never `.total` (which already carries licensing fees and tips) and
|
||||
// never `buzzAmount`. Same rule as the txt2img path.
|
||||
baseGenerationBuzz: realizedBaseCost,
|
||||
// …and whether that price is a CAP. True → no fee is computed, under its
|
||||
// own skip reason. Same rule, same single place, as the txt2img path.
|
||||
generationPriceIsCap: realizedPriceIsCap,
|
||||
});
|
||||
})().catch(() => {
|
||||
/* best-effort: a failed attribution write never breaks submit */
|
||||
@@ -9880,6 +9956,25 @@ async function submitPassThroughStepWorkflow(opts: {
|
||||
// ── Submit. On ANY throw AFTER reserving, refund the CEILING on ALL keys.
|
||||
let snapshot: ReturnType<typeof snapshotFromWorkflow>;
|
||||
let realizedTransactions: Awaited<ReturnType<typeof submitWorkflow>>['transactions'];
|
||||
// The orchestrator's BASE cost, for the DARK author-fee observation. Hoisted
|
||||
// for the same reason as `realizedTransactions`, and read off the raw response
|
||||
// rather than `snapshot`, for the reason spelled out on the txt2img path.
|
||||
let realizedBaseCost: number | null = null;
|
||||
// 🔴 `WorkflowCost.variable` — TRUE when the price is a CAP that may settle
|
||||
// lower. THIS PATH IS THE MOTIVATING CASE: its spend basis is
|
||||
// `snapshot.cost?.total ?? ceiling`, i.e. post-billed against a reserved
|
||||
// ceiling, and the settle hook below refunds `ceiling - actual`. Charging an
|
||||
// author fee on the cap would be a fee on money the viewer gets back. The
|
||||
// author fee is therefore SKIPPED on a cap — under its own counted skip
|
||||
// reason, not folded into `base-unavailable`. See
|
||||
// `BLOCK_AUTHOR_FEE_PRICE_IS_CAP`; whether a cap-priced path should EVER
|
||||
// charge, and on what number, is slice 2's to settle.
|
||||
//
|
||||
// 🔴 And on THIS path above all: an ABSENT `variable` reads as a final price,
|
||||
// the FAIL-OPEN direction — see the txt2img hoist for why `=== true` merges
|
||||
// "not-a-cap" with "the orchestrator did not say", and why resolving that
|
||||
// tri-state is slice 2's call rather than this slice's.
|
||||
let realizedPriceIsCap: boolean | null = null;
|
||||
const submittedAt = Date.now();
|
||||
try {
|
||||
const submitted = await submitWorkflow({
|
||||
@@ -9897,6 +9992,8 @@ async function submitPassThroughStepWorkflow(opts: {
|
||||
});
|
||||
snapshot = snapshotFromWorkflow(submitted);
|
||||
realizedTransactions = submitted.transactions;
|
||||
realizedBaseCost = typeof submitted.cost?.base === 'number' ? submitted.cost.base : null;
|
||||
realizedPriceIsCap = submitted.cost?.variable === true;
|
||||
} catch (e) {
|
||||
await refundBlockBuzzReservation(reservation, ceiling);
|
||||
if (appSpendReserve) {
|
||||
@@ -10011,6 +10108,18 @@ async function submitPassThroughStepWorkflow(opts: {
|
||||
// (its `.strict()` wire shape has neither).
|
||||
modelId: null,
|
||||
sharedContentKey: null,
|
||||
// BASE generation cost for the DARK author-fee observation — `.base`,
|
||||
// never `.total` (which already carries licensing fees and tips) and
|
||||
// never `buzzAmount`. Same rule as every other submit path.
|
||||
baseGenerationBuzz: realizedBaseCost,
|
||||
// 🔴 …and whether that price is a CAP. This path quotes a CEILING and
|
||||
// settles down (`snapshot.cost?.total ?? ceiling` above, and the settle
|
||||
// hook refunds `ceiling - actual`), so on a cap-priced step the fee is
|
||||
// deliberately SKIPPED — a percentage of a number the viewer is partly
|
||||
// refunded is a fee on money they did not spend. It is a NAMED,
|
||||
// COUNTED skip (`price-is-cap`), never folded into `base-unavailable`,
|
||||
// and slice 2 owns the question of what a cap-priced path should charge.
|
||||
generationPriceIsCap: realizedPriceIsCap,
|
||||
});
|
||||
})().catch(() => {
|
||||
/* best-effort: a failed attribution write never breaks submit */
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
import { readFileSync } from 'fs';
|
||||
import path from 'path';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
/**
|
||||
* THE SEAM GUARD — and the only guard in this change that is RED at `origin/main`.
|
||||
*
|
||||
* It lives HERE, under the `no-*.test.ts` convention-guard name, rather than
|
||||
* beside the module it protects: this is a source-text structural guard over a
|
||||
* call-site population, which is exactly the class `src/server/services/__tests__/no-*.test.ts`
|
||||
* exists for. `no-lint-rules-script-drift` only scans THIS directory for THIS
|
||||
* name shape, so a guard of this class parked anywhere else is invisible to the
|
||||
* ratchet and is not run by the fast `pnpm run test:lint-rules` selector — it
|
||||
* would surface only in the full unit suite, minutes later, in a file nobody
|
||||
* was looking at.
|
||||
*
|
||||
* `computeBlockAuthorFee` is hermetically covered by
|
||||
* `src/server/services/blocks/__tests__/author-fee.test.ts`, and
|
||||
* `recordSpendAttribution` has its own suite. Both can be green while the
|
||||
* feature is inert, because the defect lives in the seam neither of them owns:
|
||||
* the author fee is a percentage of `WorkflowCost.base`, and the spend path's
|
||||
* `buzzAmount` is a DIFFERENT number — the realized paid debit, which already
|
||||
* carries the per-resource model licensing fees, the lineage fee and the
|
||||
* viewer's tips. Both are plain positive Buzz integers, so nothing downstream
|
||||
* can tell them apart; feeding the wrong one produces a plausible fee that takes
|
||||
* a cut of another creator's licensing fee and compounds as fee-charging
|
||||
* resources stack.
|
||||
*
|
||||
* So this pins a RELATIONSHIP over the whole population rather than a component:
|
||||
* EVERY `recordSpendAttribution` call site must be fed a base AND the cap flag
|
||||
* that governs whether a fee may be computed on it, and both must come from the
|
||||
* RAW ORCHESTRATOR RESPONSE — `submitted.cost.base` / `submitted.cost.variable`,
|
||||
* hoisted into `realizedBaseCost` / `realizedPriceIsCap` — and NEVER from
|
||||
* `snapshot`. `BlockWorkflowSnapshot.cost` is deliberately `{ total }` only,
|
||||
* because widening that wire shape would publish the platform's cost breakdown to
|
||||
* every third-party app; so `snapshot` cannot supply either, and a
|
||||
* `snapshot.cost?.base` in the router would be `undefined` silently. The asserted
|
||||
* count makes the ledger fail when the set GROWS (a new submit path added without
|
||||
* a base) as well as when it SHRINKS.
|
||||
*
|
||||
* ⚠️ THE COUNT WENT 3 → 4 ON 2026-09-17, AND THAT IS THE GUARD WORKING. A fourth
|
||||
* `recordSpendAttribution` call site — `submitPassThroughStepWorkflow` — landed
|
||||
* on `main` while this change was in review, passing no base. Nothing in this
|
||||
* branch's own commits broke; the ledger caught a call site the branch had never
|
||||
* seen. Bumping the number is only half the fix: the new site is WIRED (base +
|
||||
* cap flag) rather than exempted, because an exemption is what the ledger exists
|
||||
* to make impossible.
|
||||
*
|
||||
* It is a SOURCE-TEXT guard by necessity: the three call sites are inside a
|
||||
* ~9,000-line tRPC router whose handlers cannot be invoked without the whole
|
||||
* orchestrator + auth stack. That makes it structural, so `author-fee.test.ts`
|
||||
* carries the behavioural half; a structural check alone would type-check past a
|
||||
* wrong argument.
|
||||
*/
|
||||
|
||||
const ROUTER = path.join(process.cwd(), 'src/server/routers/blocks.router.ts');
|
||||
|
||||
/** Every `recordSpendAttribution({ … })` argument object in the router source. */
|
||||
function spendAttributionCallSites(source: string): string[] {
|
||||
const sites: string[] = [];
|
||||
const opener = 'recordSpendAttribution({';
|
||||
let from = 0;
|
||||
for (;;) {
|
||||
const start = source.indexOf(opener, from);
|
||||
if (start === -1) break;
|
||||
// Walk braces from the argument object's `{` to its match so a nested object
|
||||
// literal (every call site has several) cannot end the slice early.
|
||||
let depth = 0;
|
||||
let i = start + opener.length - 1;
|
||||
for (; i < source.length; i++) {
|
||||
if (source[i] === '{') depth++;
|
||||
else if (source[i] === '}') {
|
||||
depth--;
|
||||
if (depth === 0) break;
|
||||
}
|
||||
}
|
||||
sites.push(source.slice(start, i + 1));
|
||||
from = i + 1;
|
||||
}
|
||||
return sites;
|
||||
}
|
||||
|
||||
describe('author fee — the spend-attribution seam', () => {
|
||||
const source = readFileSync(ROUTER, 'utf8');
|
||||
const sites = spendAttributionCallSites(source);
|
||||
|
||||
it('the extractor itself finds call sites (positive control)', () => {
|
||||
// Without this, an extractor that silently matched nothing would make every
|
||||
// assertion below vacuously true over an empty array.
|
||||
expect(sites.length).toBeGreaterThan(0);
|
||||
expect(sites[0]).toContain('workflowId');
|
||||
});
|
||||
|
||||
it('there are exactly FOUR spend-attribution call sites', () => {
|
||||
// textToImage, customComfy, the registry-step bridge, and the pass-through
|
||||
// step. A new submit path is a deliberate decision about whether it charges
|
||||
// an author fee, so it should land here rather than silently inherit a skip.
|
||||
expect(sites).toHaveLength(4);
|
||||
});
|
||||
|
||||
it('every call site passes a base generation cost', () => {
|
||||
for (const site of sites) expect(site).toContain('baseGenerationBuzz:');
|
||||
});
|
||||
|
||||
it('every call site passes the hoisted `realizedBaseCost`, not a cost total', () => {
|
||||
for (const site of sites) {
|
||||
expect(site).toContain('baseGenerationBuzz: realizedBaseCost');
|
||||
expect(site).not.toMatch(
|
||||
/baseGenerationBuzz:\s*(buzzAmount|cost\b|snapshot\.cost\?\.total|ceiling|reserveBuzz)/
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it('every call site passes the CAP FLAG that governs whether a fee may be charged', () => {
|
||||
// 🔴 THE SECOND HALF OF THE SEAM, AND IT FAILS THE SAME WAY THE FIRST DID.
|
||||
// A base alone is not enough to decide a fee: `WorkflowCost.variable` says
|
||||
// the price is a CAP that settles lower, and a percentage of a cap is a fee
|
||||
// on money the viewer gets refunded. A site that hoists the base but drops
|
||||
// the cap flag computes a plausible fee on provisional money — the same
|
||||
// class of silently-wrong number as feeding `buzzAmount`, and equally
|
||||
// invisible downstream, because both are plain Buzz integers.
|
||||
for (const site of sites) {
|
||||
expect(site).toContain('generationPriceIsCap: realizedPriceIsCap');
|
||||
expect(site).not.toMatch(/generationPriceIsCap:\s*(false|true|null|undefined)\b/);
|
||||
}
|
||||
});
|
||||
|
||||
it('`realizedBaseCost` and `realizedPriceIsCap` are read from the orchestrator response', () => {
|
||||
// 🔴 The wire shape a block sees (`BlockWorkflowSnapshot.cost`) is
|
||||
// `{ total }` only, so `snapshot` CANNOT supply either — they have to come
|
||||
// off the raw submit response. This pins that, and pins the count, so a new
|
||||
// submit path cannot hoist a base from the total by copy-paste.
|
||||
//
|
||||
// 🔴 WHAT THIS GUARD DOES NOT COVER, STATED SO IT IS NOT MISTAKEN FOR
|
||||
// COVERAGE. It pins that the cap flag is read from the RIGHT OBJECT; it says
|
||||
// nothing about the orchestrator declining to send the field at all.
|
||||
// `WorkflowCost.variable` is `?: null | boolean`, so the pinned
|
||||
// `submitted.cost?.variable === true` maps BOTH `undefined` and `null` to
|
||||
// "not a cap" — an absent field is treated as a FINAL PRICE, and the fee is
|
||||
// computed. That is the fail-OPEN direction, the same direction as reading
|
||||
// `snapshot.cost?.variable`, reached by a different route: there the wrong
|
||||
// object is silent, here the right object is. Inert while slice 1 moves no
|
||||
// money; a money question the moment slice 2 settles, and resolving the
|
||||
// tri-state is SLICE 2'S POLICY CALL — this guard deliberately pins the
|
||||
// current shape rather than pre-empting it.
|
||||
const assignments = source.match(/realizedBaseCost =\s*\n?\s*typeof submitted\.cost\?\.base/g);
|
||||
expect(assignments).toHaveLength(4);
|
||||
expect(source).not.toMatch(/realizedBaseCost\s*=\s*[^;]*cost\?\.total/);
|
||||
|
||||
const capAssignments = source.match(
|
||||
/realizedPriceIsCap =\s*\n?\s*submitted\.cost\?\.variable === true/g
|
||||
);
|
||||
expect(capAssignments).toHaveLength(4);
|
||||
// `snapshot` has no `variable` either — reading one would be `undefined`,
|
||||
// i.e. "never a cap", which is the fail-OPEN direction.
|
||||
expect(source).not.toMatch(/realizedPriceIsCap\s*=\s*[^;]*snapshot\./);
|
||||
});
|
||||
});
|
||||
@@ -616,6 +616,57 @@ export async function isAppBlocksBackpayEnabled(): Promise<boolean> {
|
||||
return isFlipt(APP_BLOCKS_BACKPAY_FLAG);
|
||||
}
|
||||
|
||||
/**
|
||||
* Dedicated GLOBAL flag for the PER-GENERATION AUTHOR FEE (slice 1).
|
||||
*
|
||||
* The fee is an additive, author-set, viewer-paid charge on each generation an
|
||||
* app runs — `max(flatBuzz, pctOfBase × base_generation_buzz)`. Slice 1 computes
|
||||
* it and reports it to Prometheus + Axiom so the settlement slice can be sized
|
||||
* from real traffic; it moves no money and writes no row. This flag is what
|
||||
* keeps even that computation off the production path until someone asks for it.
|
||||
*
|
||||
* GLOBAL (no user context), like `app-blocks-pipeline-enabled` /
|
||||
* `app-blocks-backpay-enabled`: the only caller is the fire-and-forget
|
||||
* spend-attribution writer, which is machine-side and has no session to segment
|
||||
* on. The viewer identity is on the row, not in the gate.
|
||||
*
|
||||
* OPERATOR NOTE: `app-blocks-author-fee-enabled` EXISTS, as a PLAIN GLOBAL
|
||||
* BOOLEAN — base `enabled: false`, NO segment, no variants, no rollouts. Keep it
|
||||
* that shape. A global eval returns the flag's BASE value, so a segment can
|
||||
* neither match nor restrict: base `false` + a rollout stays dark for everyone
|
||||
* (safe but confusing), and base `true` + a rollout is ON for everyone while
|
||||
* looking restricted (not safe). See GLOBAL-EVAL SEMANTICS at the top of this
|
||||
* file.
|
||||
*
|
||||
* Fail-safe, code half: an unreachable Flipt — and an absent key — evaluates
|
||||
* `false` unconditionally, so the computation cannot run by accident.
|
||||
*
|
||||
* ⚠️ FLAG STATE, AND IT IS WEAKER THAN AN EARLIER REVISION OF THIS COMMENT SAID.
|
||||
* That revision claimed the flag does NOT exist as this merges and read the
|
||||
* resulting dark posture as something that "cannot regress open". The key was
|
||||
* created at base `false` after this branch's last commit, deliberately: an
|
||||
* ABSENT key makes the evaluation throw, bypass its cache and log a
|
||||
* `console.error` on every App Blocks generation submit, forever. Verified live
|
||||
* in the `civitai-app` environment — `BOOLEAN_FLAG_TYPE`, `enabled: false`,
|
||||
* empty `rules`/`rollouts`, global evaluation
|
||||
* `enabled:false, reason:DEFAULT_EVALUATION_REASON, segmentKeys:[]`.
|
||||
*
|
||||
* So: still dark at merge, for a weaker reason. An absent flag had to be CREATED
|
||||
* before the fee could be enabled at all; a present base-`false` flag is one
|
||||
* toggle away, with no deploy and no review. The dark posture is flag state, not
|
||||
* structure.
|
||||
*/
|
||||
export const APP_BLOCKS_AUTHOR_FEE_FLAG = 'app-blocks-author-fee-enabled';
|
||||
|
||||
/**
|
||||
* GLOBAL fail-closed gate for the per-generation AUTHOR FEE computation.
|
||||
* See APP_BLOCKS_AUTHOR_FEE_FLAG for the fail-safe reasoning, and
|
||||
* `~/server/services/blocks/author-fee` for what it gates.
|
||||
*/
|
||||
export async function isAppBlocksAuthorFeeEnabled(): Promise<boolean> {
|
||||
return isFlipt(APP_BLOCKS_AUTHOR_FEE_FLAG);
|
||||
}
|
||||
|
||||
/**
|
||||
* Dedicated mod-segmented flag for the MOD REVIEW SANDBOX (#2831 second half).
|
||||
*
|
||||
|
||||
@@ -0,0 +1,901 @@
|
||||
import { readFileSync } from 'fs';
|
||||
import path from 'path';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
/**
|
||||
* App Blocks PER-GENERATION AUTHOR FEE — slice 1 (computation + config + dark gate).
|
||||
*
|
||||
* fee = max(flatBuzz, pctOfBase × base_generation_buzz)
|
||||
*
|
||||
* WHAT THIS SUITE IS, STATED HONESTLY. `author-fee.ts` is a NEW module, so most
|
||||
* tests below are NEW-FEATURE coverage, not regression coverage: they could not
|
||||
* have been red at `origin/main` for any reason except the import failing. The
|
||||
* claim that these guards are real is carried by the MUTATION SWEEP recorded in
|
||||
* the PR body — each rule below was broken on purpose and the naming test is the
|
||||
* one that went red. Guards labelled INVARIANT GUARD pin a property the code has
|
||||
* always had rather than one this change introduced.
|
||||
*
|
||||
* THE EXCEPTION is `the PLATFORM table, as production resolves it`: those cases
|
||||
* pass NO `config` argument, so they exercise `BLOCK_AUTHOR_FEE_PLATFORM_CONFIG`
|
||||
* itself, and they were RED at the revision that shipped `byType: []` (the
|
||||
* chat-completion cases returned the 1 ⚡ / 5% default). They are regression
|
||||
* coverage for the seeded table, not fixture coverage.
|
||||
*
|
||||
* The other genuinely red-at-base guard for this change is
|
||||
* `src/server/services/__tests__/no-divergent-author-fee-base.test.ts`, which
|
||||
* pins the router call sites.
|
||||
*
|
||||
* Every expected value below is a LITERAL computed by hand from the rule, never
|
||||
* from the implementation. The fixtures deliberately avoid the module's own
|
||||
* constants (1, 5, 100, 10000) as ANSWERS wherever a mutant could hardcode one:
|
||||
* 137 ⚡ at 5% is 6, not any constant in the file.
|
||||
*/
|
||||
|
||||
import {
|
||||
BLOCK_AUTHOR_FEE_BASIS_POINTS_SCALE,
|
||||
BLOCK_AUTHOR_FEE_DEFAULT_FLAT_BUZZ,
|
||||
BLOCK_AUTHOR_FEE_DEFAULT_PCT_OF_BASE,
|
||||
BLOCK_AUTHOR_FEE_MAX_FLAT_BUZZ,
|
||||
BLOCK_AUTHOR_FEE_MAX_PCT_BASIS_POINTS,
|
||||
BLOCK_AUTHOR_FEE_MAX_PCT_OF_BASE,
|
||||
BLOCK_AUTHOR_FEE_PLATFORM_CONFIG,
|
||||
blockAuthorFeeCeilingBasisPoints,
|
||||
clampBlockAuthorFeeParams,
|
||||
computeBlockAuthorFee,
|
||||
resolveBlockAuthorFeeParams,
|
||||
type BlockAuthorFeeConfig,
|
||||
} from '../author-fee';
|
||||
|
||||
// A configuration whose numbers share no value with the platform defaults or the
|
||||
// ceilings, so an assertion cannot pass by coincidence with a mutant that reaches
|
||||
// for a constant instead of the configured value.
|
||||
const DISTINCT: BlockAuthorFeeConfig = {
|
||||
default: { flatBuzz: 3, pctOfBase: 0.02 },
|
||||
};
|
||||
|
||||
describe('author fee — the platform defaults and ceilings', () => {
|
||||
it('defaults to 1 flat / 5% of base', () => {
|
||||
expect(BLOCK_AUTHOR_FEE_DEFAULT_FLAT_BUZZ).toBe(1);
|
||||
expect(BLOCK_AUTHOR_FEE_DEFAULT_PCT_OF_BASE).toBe(0.05);
|
||||
expect(BLOCK_AUTHOR_FEE_PLATFORM_CONFIG.default).toEqual({ flatBuzz: 1, pctOfBase: 0.05 });
|
||||
});
|
||||
|
||||
it('caps flat at 100 and percent at 100%, and imposes NO minimum on either', () => {
|
||||
expect(BLOCK_AUTHOR_FEE_MAX_FLAT_BUZZ).toBe(100);
|
||||
expect(BLOCK_AUTHOR_FEE_MAX_PCT_OF_BASE).toBe(1);
|
||||
// 0 survives the clamp on both legs — a floor would show up here.
|
||||
expect(clampBlockAuthorFeeParams({ flatBuzz: 0, pctOfBase: 0 })).toEqual({
|
||||
flatBuzz: 0,
|
||||
pctBasisPoints: 0,
|
||||
clamped: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('the ENFORCED percentage ceiling IS the DECLARED one — one spelling, not two', () => {
|
||||
// 🔴 DIVERGENCE GUARD. `BLOCK_AUTHOR_FEE_MAX_PCT_OF_BASE` is the policy
|
||||
// number; slice 3's author-input validation reads it. Until the clamp bound
|
||||
// was derived from it, the clamp capped at `BLOCK_AUTHOR_FEE_BASIS_POINTS_SCALE`
|
||||
// instead — a SECOND, independent spelling of "100%" that agreed with this
|
||||
// one only by coincidence, with no implementation reading this constant at
|
||||
// all. Moving the declared ceiling and deleting its `toBe(1)` line left
|
||||
// every test green (measured at 92646e0: 84/84, mutant SURVIVED).
|
||||
//
|
||||
// Both halves are needed and neither alone suffices:
|
||||
// (a) the RELATIONSHIP — the clamp must land at exactly the declared
|
||||
// fraction of the base, so a bound that stops tracking the constant
|
||||
// fails here;
|
||||
// (b) the LITERAL — pinned so the pair cannot simply drift together.
|
||||
const base = 640;
|
||||
const overCeiling = computeBlockAuthorFee({
|
||||
baseGenerationBuzz: base,
|
||||
generationType: 'textToImage',
|
||||
// 900% — far above any plausible ceiling, so this exercises the bound
|
||||
// itself rather than the identity case.
|
||||
config: { default: { flatBuzz: 0, pctOfBase: 9 } },
|
||||
});
|
||||
expect(
|
||||
overCeiling.pctLegBuzz,
|
||||
'the ENFORCED percentage ceiling has diverged from BLOCK_AUTHOR_FEE_MAX_PCT_OF_BASE'
|
||||
).toBe(Math.floor(BLOCK_AUTHOR_FEE_MAX_PCT_OF_BASE * base)); // (a)
|
||||
expect(BLOCK_AUTHOR_FEE_MAX_PCT_OF_BASE).toBe(1); // (b)
|
||||
expect(overCeiling.pctLegBuzz).toBe(640); // (b), behaviourally
|
||||
expect(overCeiling.clamped).toBe(true);
|
||||
});
|
||||
|
||||
it('the ceiling is quantized DOWN — the enforced bound never sits ABOVE the declared policy', () => {
|
||||
// 🔴 DIRECTION GUARD, and it needs a ceiling the shipped policy constant
|
||||
// cannot express. `BLOCK_AUTHOR_FEE_MAX_PCT_OF_BASE` is 1, where flooring
|
||||
// and rounding are both 10000 — so NOTHING asserted about the shipped
|
||||
// constant can tell the two apart, and the derivation shipped with
|
||||
// `Math.round` for exactly that reason. Calling the derivation at a ceiling
|
||||
// finer than a basis point is what makes the direction observable.
|
||||
//
|
||||
// `toBasisPoints` floors because the module promises "a stated 5% never
|
||||
// charges more than 5%". Rounding the CEILING the other way enforces a bound
|
||||
// ABOVE the declared policy: 12.3456% would derive to 1235 bp = 12.35%.
|
||||
expect(
|
||||
blockAuthorFeeCeilingBasisPoints(0.123456),
|
||||
'the ceiling derivation ROUNDS — it must floor, like the quantization it governs'
|
||||
).toBe(1234);
|
||||
// The mutant's answer, pinned so the two are visibly different numbers and
|
||||
// this case cannot quietly stop discriminating.
|
||||
expect(Math.round(0.123456 * BLOCK_AUTHOR_FEE_BASIS_POINTS_SCALE)).toBe(1235);
|
||||
|
||||
// The property, at four ceilings that each round UP: the enforced bound is
|
||||
// never above the declared fraction expressed in basis points.
|
||||
for (const pct of [0.123456, 0.049999, 0.00005, 0.9999999]) {
|
||||
expect(
|
||||
blockAuthorFeeCeilingBasisPoints(pct),
|
||||
`a declared ceiling of ${pct} derived to an enforced bound ABOVE it`
|
||||
).toBeLessThanOrEqual(pct * BLOCK_AUTHOR_FEE_BASIS_POINTS_SCALE);
|
||||
}
|
||||
|
||||
// …and the shipped constant IS that derivation applied to the shipped
|
||||
// policy, so the guard above is about the constant and not about a helper
|
||||
// nothing uses.
|
||||
expect(BLOCK_AUTHOR_FEE_MAX_PCT_BASIS_POINTS).toBe(
|
||||
blockAuthorFeeCeilingBasisPoints(BLOCK_AUTHOR_FEE_MAX_PCT_OF_BASE)
|
||||
);
|
||||
expect(BLOCK_AUTHOR_FEE_MAX_PCT_BASIS_POINTS).toBe(10_000);
|
||||
});
|
||||
|
||||
it('the constant is initialised BY THE NAMED FUNCTION, not by an inlined expression', () => {
|
||||
// 🔴 SOURCE-TEXT GUARD, and it exists because the behavioural pins above
|
||||
// CANNOT see the defect it covers. Re-inlining the derivation as
|
||||
// `Math.round(BLOCK_AUTHOR_FEE_MAX_PCT_OF_BASE * SCALE)` leaves
|
||||
// `blockAuthorFeeCeilingBasisPoints` in the file, still floors when called
|
||||
// directly, and still yields 10000 at the shipped policy of 1 — so the
|
||||
// direction cases, the property loop and the linking assertion are all
|
||||
// green while the SHIPPED CONSTANT is once again rounded. Measured: that
|
||||
// exact mutant SURVIVED the whole suite. The `⚠️ DO NOT INLINE IT BACK`
|
||||
// comment on the function was the only thing guarding it, and prose is not
|
||||
// a guard.
|
||||
//
|
||||
// Same technique, same reason, as the router seam guard in
|
||||
// `src/server/services/__tests__/no-divergent-author-fee-base.test.ts`: the
|
||||
// property is about which EXPRESSION ships, which no runtime value can
|
||||
// distinguish while floor and round agree.
|
||||
const source = readFileSync(
|
||||
path.join(process.cwd(), 'src/server/services/blocks/author-fee.ts'),
|
||||
'utf8'
|
||||
);
|
||||
|
||||
// Positive control: without this, a bad path or a renamed constant would
|
||||
// make every assertion below vacuously true over an empty match set.
|
||||
const initialiser = source.match(
|
||||
/export const BLOCK_AUTHOR_FEE_MAX_PCT_BASIS_POINTS[^=]*=([\s\S]*?);/
|
||||
);
|
||||
expect(
|
||||
initialiser,
|
||||
'the ceiling constant was not found in author-fee.ts — guard is scanning the wrong source'
|
||||
).not.toBeNull();
|
||||
|
||||
expect(
|
||||
initialiser?.[1],
|
||||
'BLOCK_AUTHOR_FEE_MAX_PCT_BASIS_POINTS is no longer initialised by blockAuthorFeeCeilingBasisPoints — the derivation has been INLINED BACK, which makes its rounding direction unreachable from a test'
|
||||
).toContain('blockAuthorFeeCeilingBasisPoints(');
|
||||
expect(
|
||||
initialiser?.[1],
|
||||
'the ceiling constant’s initialiser performs its own arithmetic — it must delegate to blockAuthorFeeCeilingBasisPoints, whose flooring is what the direction guard above pins'
|
||||
).not.toMatch(/Math\.\w+|\*|\//);
|
||||
});
|
||||
|
||||
it('seeds exactly ONE per-type override: chat-completion pays nothing', () => {
|
||||
expect(BLOCK_AUTHOR_FEE_PLATFORM_CONFIG.byType).toEqual([
|
||||
['chat-completion', { flatBuzz: 0, pctOfBase: 0 }],
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// THE SEEDED PLATFORM TABLE IS PRODUCTION BEHAVIOUR, not a fixture. Every test
|
||||
// in this block calls `computeBlockAuthorFee` with NO `config` argument, so it
|
||||
// exercises `BLOCK_AUTHOR_FEE_PLATFORM_CONFIG` — the object the one production
|
||||
// caller actually uses. RED at the previous revision, whose `byType` was `[]`.
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
describe('author fee — the PLATFORM table, as production resolves it', () => {
|
||||
// 640 ⚡ is none of the module's constants and its 5% (32) is not either, so a
|
||||
// mutant reaching for a constant instead of the configured value cannot pass.
|
||||
const BASE = 640;
|
||||
|
||||
it('a chat-completion charges NOTHING — the seeded override, no config argument', () => {
|
||||
const r = computeBlockAuthorFee({
|
||||
baseGenerationBuzz: BASE,
|
||||
generationType: 'chat-completion',
|
||||
});
|
||||
expect(r.feeBuzz).toBe(0);
|
||||
expect(r.flatLegBuzz).toBe(0);
|
||||
expect(r.pctLegBuzz).toBe(0);
|
||||
expect(r.governingLeg).toBe('none');
|
||||
expect(r.source).toBe('type');
|
||||
expect(r.coarseType).toBe('chat-completion');
|
||||
});
|
||||
|
||||
it('…while a SAME-BASE generation of another type pays the default 5%', () => {
|
||||
// 5% of 640 = 32. Same base, different type, different answer — which is the
|
||||
// whole point of the per-type axis being live.
|
||||
const r = computeBlockAuthorFee({ baseGenerationBuzz: BASE, generationType: 'convert-image' });
|
||||
expect(r.feeBuzz).toBe(32);
|
||||
expect(r.pctLegBuzz).toBe(32);
|
||||
expect(r.flatLegBuzz).toBe(1);
|
||||
expect(r.governingLeg).toBe('pct');
|
||||
expect(r.source).toBe('default');
|
||||
});
|
||||
|
||||
it('…and an image generation at the same base pays the same default 32 ⚡', () => {
|
||||
const r = computeBlockAuthorFee({
|
||||
baseGenerationBuzz: BASE,
|
||||
generationType: 'textToImage:img2img',
|
||||
});
|
||||
expect(r.feeBuzz).toBe(32);
|
||||
expect(r.source).toBe('default');
|
||||
});
|
||||
|
||||
it('the chat-completion zero is the OVERRIDE, not a zero base — a cheap image still pays', () => {
|
||||
// 7 ⚡ base: the flat leg governs everywhere the override does not apply.
|
||||
expect(
|
||||
computeBlockAuthorFee({ baseGenerationBuzz: 7, generationType: 'chat-completion' }).feeBuzz
|
||||
).toBe(0);
|
||||
expect(
|
||||
computeBlockAuthorFee({ baseGenerationBuzz: 7, generationType: 'textToImage' }).feeBuzz
|
||||
).toBe(1);
|
||||
});
|
||||
|
||||
it('`source` is genuinely VARIABLE in production — both arms are reachable', () => {
|
||||
// The property item 4's log-field decision rests on: with an empty `byType`
|
||||
// this was a compile-time constant 'default'.
|
||||
const sources = new Set(
|
||||
(['chat-completion', 'convert-image', 'textToImage:txt2img', null] as const).map(
|
||||
(t) => computeBlockAuthorFee({ baseGenerationBuzz: BASE, generationType: t }).source
|
||||
)
|
||||
);
|
||||
expect([...sources].sort()).toEqual(['default', 'type']);
|
||||
});
|
||||
|
||||
it('`clamped` is NOT variable in production — every platform leg is inside the ceiling', () => {
|
||||
// INVARIANT GUARD, and the evidence for DROPPING `authorFeeParamsClamped`
|
||||
// from the Axiom line: no input to the production config can set it.
|
||||
for (const t of ['chat-completion', 'convert-image', 'textToImage:img2img', null]) {
|
||||
for (const base of [0, 7, 20, BASE, 4000]) {
|
||||
expect(computeBlockAuthorFee({ baseGenerationBuzz: base, generationType: t }).clamped).toBe(
|
||||
false
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('author fee — which leg governs', () => {
|
||||
it('FLAT governs when the percentage leg rounds below it', () => {
|
||||
// 5% of 7 = 0.35 -> floors to 0; flat 1 wins.
|
||||
const r = computeBlockAuthorFee({
|
||||
baseGenerationBuzz: 7,
|
||||
generationType: 'textToImage:txt2img',
|
||||
});
|
||||
expect(r.pctLegBuzz).toBe(0);
|
||||
expect(r.flatLegBuzz).toBe(1);
|
||||
expect(r.feeBuzz).toBe(1);
|
||||
expect(r.governingLeg).toBe('flat');
|
||||
});
|
||||
|
||||
it('FLAT governs at a configured value that is none of the module constants', () => {
|
||||
// 2% of 7 = 0.14 -> 0; flat 3 wins.
|
||||
const r = computeBlockAuthorFee({
|
||||
baseGenerationBuzz: 7,
|
||||
generationType: 'textToImage:txt2img',
|
||||
config: DISTINCT,
|
||||
});
|
||||
expect(r.feeBuzz).toBe(3);
|
||||
expect(r.governingLeg).toBe('flat');
|
||||
});
|
||||
|
||||
it('PERCENT governs once 5% of base clears the flat leg', () => {
|
||||
// 5% of 137 = 6.85 -> floors to 6; flat 1 loses.
|
||||
const r = computeBlockAuthorFee({
|
||||
baseGenerationBuzz: 137,
|
||||
generationType: 'textToImage:img2img',
|
||||
});
|
||||
expect(r.pctLegBuzz).toBe(6);
|
||||
expect(r.flatLegBuzz).toBe(1);
|
||||
expect(r.feeBuzz).toBe(6);
|
||||
expect(r.governingLeg).toBe('pct');
|
||||
});
|
||||
|
||||
it('the percentage leg FLOORS — it never rounds up onto the viewer', () => {
|
||||
// 5% of 139 = 6.95. A ceil/round would give 7.
|
||||
expect(
|
||||
computeBlockAuthorFee({ baseGenerationBuzz: 139, generationType: 'textToImage' }).feeBuzz
|
||||
).toBe(6);
|
||||
});
|
||||
|
||||
it('CROSSOVER: 5% × 20 ⚡ is exactly 1 ⚡, and the tie is broken toward flat', () => {
|
||||
const r = computeBlockAuthorFee({ baseGenerationBuzz: 20, generationType: 'textToImage' });
|
||||
expect(r.pctLegBuzz).toBe(1);
|
||||
expect(r.flatLegBuzz).toBe(1);
|
||||
expect(r.feeBuzz).toBe(1);
|
||||
expect(r.governingLeg).toBe('flat');
|
||||
});
|
||||
|
||||
it('one ⚡ below the crossover the flat leg is still what pays', () => {
|
||||
// 5% of 19 = 0.95 -> 0.
|
||||
const r = computeBlockAuthorFee({ baseGenerationBuzz: 19, generationType: 'textToImage' });
|
||||
expect(r.pctLegBuzz).toBe(0);
|
||||
expect(r.feeBuzz).toBe(1);
|
||||
});
|
||||
|
||||
it('feeBuzz is always max(flatLeg, pctLeg) — on every branch', () => {
|
||||
// INVARIANT GUARD: pins the shape of the result object, not a behaviour this
|
||||
// change introduced. It exists so the zero-base branch cannot quietly report
|
||||
// a flat leg it did not charge.
|
||||
for (const base of [0, 1, 19, 20, 137, 4000]) {
|
||||
const r = computeBlockAuthorFee({ baseGenerationBuzz: base, generationType: 'textToImage' });
|
||||
expect(r.feeBuzz).toBe(Math.max(r.flatLegBuzz, r.pctLegBuzz));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('author fee — zero base mints nothing', () => {
|
||||
it('a ZERO base produces a ZERO fee, even though the flat leg is 1', () => {
|
||||
// max(1, 5% × 0) would be 1. It must not be.
|
||||
const r = computeBlockAuthorFee({ baseGenerationBuzz: 0, generationType: 'textToImage' });
|
||||
expect(r.feeBuzz).toBe(0);
|
||||
expect(r.flatLegBuzz).toBe(0);
|
||||
expect(r.pctLegBuzz).toBe(0);
|
||||
expect(r.governingLeg).toBe('none');
|
||||
expect(r.baseGenerationBuzz).toBe(0);
|
||||
});
|
||||
|
||||
it('a zero base beats even a large configured flat leg', () => {
|
||||
expect(
|
||||
computeBlockAuthorFee({
|
||||
baseGenerationBuzz: 0,
|
||||
generationType: 'chat-completion',
|
||||
config: { default: { flatBuzz: 64, pctOfBase: 0.25 } },
|
||||
}).feeBuzz
|
||||
).toBe(0);
|
||||
});
|
||||
|
||||
it('a negative, NaN, or non-numeric base is treated as a zero base', () => {
|
||||
for (const base of [-42, Number.NaN, Number.POSITIVE_INFINITY, null, undefined, '20']) {
|
||||
expect(
|
||||
computeBlockAuthorFee({ baseGenerationBuzz: base, generationType: 'textToImage' })
|
||||
).toMatchObject({ feeBuzz: 0, governingLeg: 'none' });
|
||||
}
|
||||
});
|
||||
|
||||
it('a fractional base floors before the percentage is taken', () => {
|
||||
// floor(20.9) = 20 -> 5% = 1 exactly, not floor(5% of 20.9) = 1 by luck.
|
||||
const r = computeBlockAuthorFee({ baseGenerationBuzz: 20.9, generationType: 'textToImage' });
|
||||
expect(r.baseGenerationBuzz).toBe(20);
|
||||
expect(r.pctLegBuzz).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('author fee — both legs at 0', () => {
|
||||
it('a 0/0 configuration charges nothing on a large base', () => {
|
||||
const r = computeBlockAuthorFee({
|
||||
baseGenerationBuzz: 4000,
|
||||
generationType: 'textToImage',
|
||||
config: { default: { flatBuzz: 0, pctOfBase: 0 } },
|
||||
});
|
||||
expect(r.feeBuzz).toBe(0);
|
||||
expect(r.governingLeg).toBe('none');
|
||||
expect(r.clamped).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('author fee — the platform ceiling', () => {
|
||||
it('clamps the FLAT leg to 100 ⚡ and reports the clamp', () => {
|
||||
const r = computeBlockAuthorFee({
|
||||
baseGenerationBuzz: 7,
|
||||
generationType: 'textToImage',
|
||||
config: { default: { flatBuzz: 250, pctOfBase: 0.05 } },
|
||||
});
|
||||
expect(r.flatLegBuzz).toBe(100);
|
||||
expect(r.feeBuzz).toBe(100);
|
||||
expect(r.clamped).toBe(true);
|
||||
});
|
||||
|
||||
it('clamps the PERCENT leg to 100% of base and reports the clamp', () => {
|
||||
// 350% of 640 would be 2240. Clamped to 100% it is exactly the base.
|
||||
const r = computeBlockAuthorFee({
|
||||
baseGenerationBuzz: 640,
|
||||
generationType: 'textToImage',
|
||||
config: { default: { flatBuzz: 0, pctOfBase: 3.5 } },
|
||||
});
|
||||
expect(r.pctLegBuzz).toBe(640);
|
||||
expect(r.feeBuzz).toBe(640);
|
||||
expect(r.clamped).toBe(true);
|
||||
});
|
||||
|
||||
it('a negative leg collapses to 0 and counts as clamped', () => {
|
||||
expect(clampBlockAuthorFeeParams({ flatBuzz: -9, pctOfBase: -0.3 })).toEqual({
|
||||
flatBuzz: 0,
|
||||
pctBasisPoints: 0,
|
||||
clamped: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('a non-finite leg collapses to 0 and counts as clamped', () => {
|
||||
expect(clampBlockAuthorFeeParams({ flatBuzz: Number.NaN, pctOfBase: 0.05 }).flatBuzz).toBe(0);
|
||||
expect(clampBlockAuthorFeeParams({ flatBuzz: Number.NaN, pctOfBase: 0.05 }).clamped).toBe(true);
|
||||
});
|
||||
|
||||
it('a value AT the ceiling is not reported as clamped', () => {
|
||||
expect(clampBlockAuthorFeeParams({ flatBuzz: 100, pctOfBase: 1 })).toEqual({
|
||||
flatBuzz: 100,
|
||||
pctBasisPoints: 10_000,
|
||||
clamped: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('quantizes the percentage to basis points without calling that a clamp', () => {
|
||||
expect(clampBlockAuthorFeeParams({ flatBuzz: 1, pctOfBase: 0.050004 })).toEqual({
|
||||
flatBuzz: 1,
|
||||
pctBasisPoints: 500,
|
||||
clamped: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('QUANTIZATION FLOORS — a stated 4.9999% never becomes a charged 5%', () => {
|
||||
// `Math.round` gave 500 bp here, i.e. a full 5%, contradicting the module's
|
||||
// own "a stated 5% never charges more than 5%". 499 bp is the exact answer.
|
||||
expect(clampBlockAuthorFeeParams({ flatBuzz: 0, pctOfBase: 0.049999 }).pctBasisPoints).toBe(
|
||||
499
|
||||
);
|
||||
// …and it reaches the fee: 499 bp of 10,000 ⚡ is 499, not 500.
|
||||
expect(
|
||||
computeBlockAuthorFee({
|
||||
baseGenerationBuzz: 10_000,
|
||||
generationType: 'textToImage',
|
||||
config: { default: { flatBuzz: 0, pctOfBase: 0.049999 } },
|
||||
}).feeBuzz
|
||||
).toBe(499);
|
||||
});
|
||||
|
||||
it('flooring does NOT cost a basis point on an exactly-stated percentage', () => {
|
||||
// The trap a naive `Math.floor(pct * 10_000)` walks into: 0.0029 is not
|
||||
// exactly representable, so the product lands just below 29 and floors to 28.
|
||||
// An author typing 0.29% must be charged 0.29%, not 0.28%.
|
||||
expect(clampBlockAuthorFeeParams({ flatBuzz: 0, pctOfBase: 0.0029 }).pctBasisPoints).toBe(29);
|
||||
expect(clampBlockAuthorFeeParams({ flatBuzz: 0, pctOfBase: 0.0093 }).pctBasisPoints).toBe(93);
|
||||
expect(clampBlockAuthorFeeParams({ flatBuzz: 0, pctOfBase: 0.0113 }).pctBasisPoints).toBe(113);
|
||||
// Exhaustive over every exact basis point in range — the measurement the
|
||||
// `toBasisPoints` docblock quotes (573 of 10,001 wrong under a naive floor).
|
||||
for (let bp = 0; bp <= 10_000; bp++) {
|
||||
expect(clampBlockAuthorFeeParams({ flatBuzz: 0, pctOfBase: bp / 10_000 })).toEqual({
|
||||
flatBuzz: 0,
|
||||
pctBasisPoints: bp,
|
||||
clamped: false,
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('author fee — per-generation-type lookup', () => {
|
||||
const CONFIG: BlockAuthorFeeConfig = {
|
||||
default: { flatBuzz: 1, pctOfBase: 0.05 },
|
||||
byType: [
|
||||
['chat-completion', { flatBuzz: 0, pctOfBase: 0 }],
|
||||
['textToImage', { flatBuzz: 2, pctOfBase: 0.1 }],
|
||||
['textToImage:img2img', { flatBuzz: 7, pctOfBase: 0.2 }],
|
||||
],
|
||||
};
|
||||
|
||||
it("an override to 0 on one type charges nothing there — Justin's chat-completions case", () => {
|
||||
const r = computeBlockAuthorFee({
|
||||
baseGenerationBuzz: 800,
|
||||
generationType: 'chat-completion',
|
||||
config: CONFIG,
|
||||
});
|
||||
expect(r.feeBuzz).toBe(0);
|
||||
expect(r.governingLeg).toBe('none');
|
||||
});
|
||||
|
||||
it('…while every other type still pays the default', () => {
|
||||
// 5% of 800 = 40.
|
||||
const r = computeBlockAuthorFee({
|
||||
baseGenerationBuzz: 800,
|
||||
generationType: 'convert-image',
|
||||
config: CONFIG,
|
||||
});
|
||||
expect(r.feeBuzz).toBe(40);
|
||||
expect(r.source).toBe('default');
|
||||
});
|
||||
|
||||
it('the FULL type beats the COARSE key when both are configured', () => {
|
||||
// 20% of 800 = 160, from the textToImage:img2img entry — not 10% (coarse) = 80.
|
||||
const r = computeBlockAuthorFee({
|
||||
baseGenerationBuzz: 800,
|
||||
generationType: 'textToImage:img2img',
|
||||
config: CONFIG,
|
||||
});
|
||||
expect(r.feeBuzz).toBe(160);
|
||||
expect(r.source).toBe('type');
|
||||
expect(r.coarseType).toBe('textToImage');
|
||||
});
|
||||
|
||||
it('a sibling subtype with no full-type entry falls back to the COARSE key', () => {
|
||||
// 10% of 800 = 80, from the bare textToImage entry.
|
||||
const r = computeBlockAuthorFee({
|
||||
baseGenerationBuzz: 800,
|
||||
generationType: 'textToImage:txt2img',
|
||||
config: CONFIG,
|
||||
});
|
||||
expect(r.feeBuzz).toBe(80);
|
||||
expect(r.source).toBe('coarse');
|
||||
expect(r.coarseType).toBe('textToImage');
|
||||
});
|
||||
|
||||
it('a bare coarse value matches its own entry and reports it as a full-type hit', () => {
|
||||
const r = resolveBlockAuthorFeeParams(CONFIG, 'textToImage');
|
||||
expect(r.params).toEqual({ flatBuzz: 2, pctOfBase: 0.1 });
|
||||
expect(r.source).toBe('type');
|
||||
});
|
||||
|
||||
it('a coarse key with no entry at all falls through to the default', () => {
|
||||
const r = resolveBlockAuthorFeeParams(CONFIG, 'customComfy:inline');
|
||||
expect(r.source).toBe('default');
|
||||
expect(r.coarseType).toBe('customComfy');
|
||||
});
|
||||
});
|
||||
|
||||
describe('author fee — an unresolvable generation type', () => {
|
||||
const CONFIG: BlockAuthorFeeConfig = {
|
||||
default: { flatBuzz: 1, pctOfBase: 0.05 },
|
||||
byType: [['textToImage', { flatBuzz: 2, pctOfBase: 0.1 }]],
|
||||
};
|
||||
|
||||
it('NULL resolves to the default parameters, and to a null coarse key', () => {
|
||||
// 5% of 800 = 40 (the default), not 80 (the textToImage override).
|
||||
const r = computeBlockAuthorFee({
|
||||
baseGenerationBuzz: 800,
|
||||
generationType: null,
|
||||
config: CONFIG,
|
||||
});
|
||||
expect(r.feeBuzz).toBe(40);
|
||||
expect(r.source).toBe('default');
|
||||
expect(r.coarseType).toBeNull();
|
||||
});
|
||||
|
||||
it('a string that is not a generation type resolves to the default, never to an override', () => {
|
||||
for (const bogus of ['videoToVideo', 'textToImage:nope', 'textToImage:', 'TEXTTOIMAGE', '']) {
|
||||
const r = resolveBlockAuthorFeeParams(CONFIG, bogus);
|
||||
expect(r.source).toBe('default');
|
||||
expect(r.coarseType).toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
it('a prototype key cannot reach an override — the key is bounded before lookup', () => {
|
||||
// INVARIANT GUARD. `isBlockGenerationType` already refuses these, so this can
|
||||
// never have failed; it is written to record WHY no prototype-key guard is
|
||||
// coded in the resolver (an unreachable guard would read as coverage while
|
||||
// providing none), not to claim coverage of its own.
|
||||
for (const key of ['toString', 'constructor', '__proto__', 'hasOwnProperty']) {
|
||||
expect(resolveBlockAuthorFeeParams(CONFIG, key).source).toBe('default');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// The DARK GATE. `observeBlockAuthorFee` is the only production entry point, and
|
||||
// with the flag off the computation must be unreachable and emit NOTHING.
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
const { mockIsFlipt, mockObserved, mockFeeBuzz, mockBaseBuzz } = vi.hoisted(() => ({
|
||||
mockIsFlipt: vi.fn(),
|
||||
mockObserved: vi.fn(),
|
||||
mockFeeBuzz: vi.fn(),
|
||||
mockBaseBuzz: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('~/server/flipt/client', () => ({ isFlipt: mockIsFlipt }));
|
||||
vi.mock('~/server/prom/client', () => ({
|
||||
blockAuthorFeeObservedCounter: { inc: mockObserved },
|
||||
blockAuthorFeeBuzzCounter: { inc: mockFeeBuzz },
|
||||
blockAuthorFeeBaseBuzzCounter: { inc: mockBaseBuzz },
|
||||
}));
|
||||
|
||||
// `observeBlockAuthorFee`'s dependencies are ordinary STATIC imports; what makes
|
||||
// the mocks above take effect is `vi.mock` hoisting, not this import's position.
|
||||
// (An earlier revision of this comment said "the dynamic imports inside
|
||||
// `observeBlockAuthorFee` resolve to them" — there are none; the same claim was
|
||||
// already deleted from the module's own docblock.)
|
||||
import { observeBlockAuthorFee } from '../author-fee';
|
||||
import { APP_BLOCKS_AUTHOR_FEE_FLAG } from '~/server/services/app-blocks-flag';
|
||||
|
||||
beforeEach(() => {
|
||||
mockIsFlipt.mockReset();
|
||||
mockObserved.mockReset();
|
||||
mockFeeBuzz.mockReset();
|
||||
mockBaseBuzz.mockReset();
|
||||
});
|
||||
|
||||
describe('observeBlockAuthorFee — fail-closed dark gate', () => {
|
||||
it('reads the dedicated author-fee flag key, globally', async () => {
|
||||
expect(APP_BLOCKS_AUTHOR_FEE_FLAG).toBe('app-blocks-author-fee-enabled');
|
||||
mockIsFlipt.mockResolvedValue(true);
|
||||
await observeBlockAuthorFee({ baseGenerationBuzz: 137, generationType: 'textToImage' });
|
||||
expect(mockIsFlipt).toHaveBeenCalledWith('app-blocks-author-fee-enabled');
|
||||
expect(mockIsFlipt).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('with the flag OFF: no computation reaches any signal, and the skip is named', async () => {
|
||||
mockIsFlipt.mockResolvedValue(false);
|
||||
const r = await observeBlockAuthorFee({
|
||||
baseGenerationBuzz: 137,
|
||||
generationType: 'textToImage',
|
||||
});
|
||||
expect(r).toEqual({ observed: false, reason: 'flag-disabled' });
|
||||
// ⚠️ WHAT THIS PINS, EXACTLY: that NOTHING IS EMITTED on the disabled path —
|
||||
// not even the skip counter. It does NOT pin the gate's POSITION. An earlier
|
||||
// revision of this comment claimed "a gate moved below the computation would
|
||||
// show up right here", and that was false: `computeBlockAuthorFee` is pure,
|
||||
// so a copy of it hoisted above the flag read emits nothing either and every
|
||||
// assertion below stays green (measured at 92646e0: this file +
|
||||
// spend-attribution.service.test.ts, 84/84, mutant SURVIVED).
|
||||
// The ordering guard is the next test.
|
||||
expect(mockObserved).not.toHaveBeenCalled();
|
||||
expect(mockFeeBuzz).not.toHaveBeenCalled();
|
||||
expect(mockBaseBuzz).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('with the flag OFF it does not touch its own ARGUMENTS — the gate really is first', async () => {
|
||||
// 🔴 THE ORDERING GUARD. The emptiness of the counters cannot see a hoisted
|
||||
// PURE computation, so observe the one thing any invocation of
|
||||
// `computeBlockAuthorFee` must do regardless of purity: READ ITS INPUTS.
|
||||
// `generationType` and `config` are read nowhere in `observeBlockAuthorFee`
|
||||
// except inside the `computeBlockAuthorFee(...)` call, so a read with the
|
||||
// flag off means the computation ran before the gate.
|
||||
//
|
||||
// Slice 1's computation is pure, so this is cheap insurance; slice 2 replaces
|
||||
// it with code that moves money, and then this is the guard that matters.
|
||||
let generationTypeReads = 0;
|
||||
let configReads = 0;
|
||||
const probe = {
|
||||
baseGenerationBuzz: 137,
|
||||
get generationType() {
|
||||
generationTypeReads++;
|
||||
return 'textToImage';
|
||||
},
|
||||
get config() {
|
||||
configReads++;
|
||||
return undefined;
|
||||
},
|
||||
};
|
||||
|
||||
mockIsFlipt.mockResolvedValue(false);
|
||||
await observeBlockAuthorFee(probe);
|
||||
expect(
|
||||
generationTypeReads,
|
||||
'flag OFF: `generationType` was read, so the fee computation ran BEFORE the gate'
|
||||
).toBe(0);
|
||||
expect(
|
||||
configReads,
|
||||
'flag OFF: `config` was read, so the fee computation ran BEFORE the gate'
|
||||
).toBe(0);
|
||||
|
||||
// POSITIVE CONTROL, in the same test: the probe CAN observe the reads, so the
|
||||
// two zeros above are a fact about the gate and not about a getter wired to
|
||||
// nothing.
|
||||
mockIsFlipt.mockResolvedValue(true);
|
||||
await observeBlockAuthorFee(probe);
|
||||
// The control proper is the ZERO case — it says only that the getters CAN
|
||||
// fire, which is what licenses reading the two zeros above as a fact about
|
||||
// the gate. It is asserted separately from exactness on purpose: this
|
||||
// message used to sit on a `toBe(1)`, so an over-read (2) failed with
|
||||
// "probe wired to nothing — flag ON read nothing: expected 2 to be 1",
|
||||
// naming the one cause the number rules out and sending the reader into the
|
||||
// probe when the change is downstream of the gate.
|
||||
expect(generationTypeReads, 'probe wired to nothing — flag ON read nothing').toBeGreaterThan(0);
|
||||
expect(configReads, 'probe wired to nothing — flag ON read nothing').toBeGreaterThan(0);
|
||||
|
||||
// Exactness is its own claim, with its own cause: each argument is read
|
||||
// exactly once past the gate. A second read is not a probe failure.
|
||||
expect(
|
||||
generationTypeReads,
|
||||
'`generationType` was read more than once past the gate — a change downstream of the flag, not a broken probe'
|
||||
).toBe(1);
|
||||
expect(
|
||||
configReads,
|
||||
'`config` was read more than once past the gate — a change downstream of the flag, not a broken probe'
|
||||
).toBe(1);
|
||||
});
|
||||
|
||||
it('a flag read that REJECTS is treated as off, never as on', async () => {
|
||||
mockIsFlipt.mockRejectedValue(new Error('flipt unreachable'));
|
||||
await expect(
|
||||
observeBlockAuthorFee({ baseGenerationBuzz: 137, generationType: 'textToImage' })
|
||||
).resolves.toEqual({ observed: false, reason: 'flag-disabled' });
|
||||
expect(mockFeeBuzz).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('with the flag ON: computes, and reports fee + base to the counters', async () => {
|
||||
mockIsFlipt.mockResolvedValue(true);
|
||||
const r = await observeBlockAuthorFee({
|
||||
baseGenerationBuzz: 137,
|
||||
generationType: 'textToImage:img2img',
|
||||
});
|
||||
expect(r).toMatchObject({ observed: true });
|
||||
if (!r.observed) throw new Error('unreachable');
|
||||
expect(r.computation.feeBuzz).toBe(6);
|
||||
expect(mockObserved).toHaveBeenCalledWith({ coarse_type: 'textToImage', outcome: 'pct' });
|
||||
expect(mockFeeBuzz).toHaveBeenCalledWith({ coarse_type: 'textToImage' }, 6);
|
||||
expect(mockBaseBuzz).toHaveBeenCalledWith({ coarse_type: 'textToImage' }, 137);
|
||||
});
|
||||
|
||||
it('labels an unresolvable generation type `unknown` rather than dropping the sample', async () => {
|
||||
mockIsFlipt.mockResolvedValue(true);
|
||||
await observeBlockAuthorFee({ baseGenerationBuzz: 137, generationType: null });
|
||||
expect(mockObserved).toHaveBeenCalledWith({ coarse_type: 'unknown', outcome: 'pct' });
|
||||
expect(mockFeeBuzz).toHaveBeenCalledWith({ coarse_type: 'unknown' }, 6);
|
||||
});
|
||||
|
||||
it('an ABSENT base is its own counted skip — never folded into the zero-base bucket', async () => {
|
||||
mockIsFlipt.mockResolvedValue(true);
|
||||
const r = await observeBlockAuthorFee({
|
||||
baseGenerationBuzz: null,
|
||||
generationType: 'textToImage',
|
||||
});
|
||||
expect(r).toEqual({ observed: false, reason: 'base-unavailable' });
|
||||
// ONE SPELLING across both instruments: the counter's `outcome` label is the
|
||||
// same string as the log line's `authorFeeSkipped`, so a sizing read can join
|
||||
// them. It used to be `base_unavailable` here and `base-unavailable` there.
|
||||
expect(mockObserved).toHaveBeenCalledWith({
|
||||
coarse_type: 'unknown',
|
||||
outcome: 'base-unavailable',
|
||||
});
|
||||
// The money counters must stay untouched, or the sizing read gains a
|
||||
// phantom zero-fee sample for a generation the fee never saw.
|
||||
expect(mockFeeBuzz).not.toHaveBeenCalled();
|
||||
expect(mockBaseBuzz).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('a CAP price charges NOTHING — its own named skip, on a base that WOULD have paid', async () => {
|
||||
// 🔴 THE CAP GUARD, ON AN INPUT NO EARLIER CHECK REJECTS. Flag ON (past the
|
||||
// dark gate), base 640 — the SAME base the `convert-image` case two tests
|
||||
// down pays 32 ⚡ on. So the only thing standing between this generation and
|
||||
// a 32 ⚡ fee is the cap branch; delete that branch and this is a 32 ⚡
|
||||
// observation, not a skip, and this assertion is what says so.
|
||||
//
|
||||
// WHY NO FEE: `WorkflowCost.variable` means the quoted price is a ceiling
|
||||
// that settles lower — the viewer is charged the maximum up front and
|
||||
// refunded the difference. A percentage of that is a fee on money they did
|
||||
// not ultimately spend.
|
||||
mockIsFlipt.mockResolvedValue(true);
|
||||
const r = await observeBlockAuthorFee({
|
||||
baseGenerationBuzz: 640,
|
||||
priceIsCap: true,
|
||||
generationType: 'convert-image',
|
||||
});
|
||||
expect(
|
||||
r,
|
||||
'a CAP-priced generation must record NO fee — a percentage of a cap charges the viewer for money that gets refunded'
|
||||
).toEqual({ observed: false, reason: 'price-is-cap' });
|
||||
// Counted, not silent: how much traffic is cap-priced is a number slice 2
|
||||
// needs in order to decide what a cap-priced path should charge.
|
||||
expect(mockObserved).toHaveBeenCalledWith({
|
||||
coarse_type: 'unknown',
|
||||
outcome: 'price-is-cap',
|
||||
});
|
||||
// 🔴 And NOT in the `base-unavailable` bucket, which is one of the two
|
||||
// denominators the slice-2 sizing read divides by. Folding a different cause
|
||||
// into it is how that denominator acquires a silent bias.
|
||||
expect(mockObserved).not.toHaveBeenCalledWith(
|
||||
expect.objectContaining({ outcome: 'base-unavailable' })
|
||||
);
|
||||
// The money counters must stay untouched — a cap-priced generation is not a
|
||||
// zero-fee sample, it is a generation the fee declined to price.
|
||||
expect(mockFeeBuzz).not.toHaveBeenCalled();
|
||||
expect(mockBaseBuzz).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('a cap-priced generation with NO base is `price-is-cap`, not `base-unavailable`', async () => {
|
||||
// The ORDER of the two checks, pinned. `base-unavailable` is the RECOVERABLE
|
||||
// blind spot — "the fee would have fired if the orchestrator had given us a
|
||||
// number" — and a cap-priced job would not have fired either way. Counting
|
||||
// it there would overstate exactly the population slice 2 sizes its recovery
|
||||
// work against.
|
||||
mockIsFlipt.mockResolvedValue(true);
|
||||
const r = await observeBlockAuthorFee({
|
||||
baseGenerationBuzz: null,
|
||||
priceIsCap: true,
|
||||
generationType: 'convert-image',
|
||||
});
|
||||
expect(r).toEqual({ observed: false, reason: 'price-is-cap' });
|
||||
expect(mockObserved).toHaveBeenCalledWith({
|
||||
coarse_type: 'unknown',
|
||||
outcome: 'price-is-cap',
|
||||
});
|
||||
expect(mockObserved).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('the CAP branch is FAIL-CLOSED-to-charging only on an explicit `true`', async () => {
|
||||
// The negative arm — without it, a mutant widening the test to any truthy
|
||||
// value (or to `!== false`) would survive, and every ordinary generation
|
||||
// would silently stop being priced. `undefined`/`null`/`false` all mean "the
|
||||
// price is final", which is the majority of traffic.
|
||||
mockIsFlipt.mockResolvedValue(true);
|
||||
for (const priceIsCap of [undefined, null, false] as const) {
|
||||
mockObserved.mockClear();
|
||||
const r = await observeBlockAuthorFee({
|
||||
baseGenerationBuzz: 640,
|
||||
priceIsCap,
|
||||
generationType: 'convert-image',
|
||||
});
|
||||
expect(r, `priceIsCap=${String(priceIsCap)} must still be priced`).toMatchObject({
|
||||
observed: true,
|
||||
});
|
||||
if (!r.observed) throw new Error('unreachable');
|
||||
expect(r.computation.feeBuzz).toBe(32);
|
||||
expect(mockObserved).toHaveBeenCalledWith({ coarse_type: 'convert-image', outcome: 'pct' });
|
||||
}
|
||||
});
|
||||
|
||||
it('the CAP skip is still behind the dark gate — flag OFF emits nothing at all', async () => {
|
||||
// Ordering: the flag is read FIRST. A cap-priced generation with the flag off
|
||||
// is a `flag-disabled` skip and emits no counter, exactly like every other.
|
||||
mockIsFlipt.mockResolvedValue(false);
|
||||
const r = await observeBlockAuthorFee({
|
||||
baseGenerationBuzz: 640,
|
||||
priceIsCap: true,
|
||||
generationType: 'convert-image',
|
||||
});
|
||||
expect(r).toEqual({ observed: false, reason: 'flag-disabled' });
|
||||
expect(mockObserved).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('a genuine ZERO base IS observed, and lands in the `none` bucket', async () => {
|
||||
mockIsFlipt.mockResolvedValue(true);
|
||||
const r = await observeBlockAuthorFee({ baseGenerationBuzz: 0, generationType: 'textToImage' });
|
||||
expect(r).toMatchObject({ observed: true });
|
||||
expect(mockObserved).toHaveBeenCalledWith({ coarse_type: 'textToImage', outcome: 'none' });
|
||||
expect(mockFeeBuzz).toHaveBeenCalledWith({ coarse_type: 'textToImage' }, 0);
|
||||
});
|
||||
|
||||
it('a chat-completion reaches the counters as an OBSERVED zero, not as a skip', async () => {
|
||||
// End-to-end through the production entry point with NO config override: the
|
||||
// seeded platform table must be what answers. A zero-fee generation is still
|
||||
// a generation the fee SAW — it lands in the `none` bucket and contributes a
|
||||
// base to the denominator, which is how the sizing read can tell "charged
|
||||
// nothing" apart from "never looked".
|
||||
mockIsFlipt.mockResolvedValue(true);
|
||||
const r = await observeBlockAuthorFee({
|
||||
baseGenerationBuzz: 640,
|
||||
generationType: 'chat-completion',
|
||||
});
|
||||
expect(r).toMatchObject({ observed: true });
|
||||
if (!r.observed) throw new Error('unreachable');
|
||||
expect(r.computation.feeBuzz).toBe(0);
|
||||
expect(r.computation.source).toBe('type');
|
||||
expect(mockObserved).toHaveBeenCalledWith({
|
||||
coarse_type: 'chat-completion',
|
||||
outcome: 'none',
|
||||
});
|
||||
expect(mockFeeBuzz).toHaveBeenCalledWith({ coarse_type: 'chat-completion' }, 0);
|
||||
expect(mockBaseBuzz).toHaveBeenCalledWith({ coarse_type: 'chat-completion' }, 640);
|
||||
});
|
||||
|
||||
it('a same-base convert-image reaches the counters with the default 32 ⚡', async () => {
|
||||
mockIsFlipt.mockResolvedValue(true);
|
||||
const r = await observeBlockAuthorFee({
|
||||
baseGenerationBuzz: 640,
|
||||
generationType: 'convert-image',
|
||||
});
|
||||
expect(r).toMatchObject({ observed: true });
|
||||
if (!r.observed) throw new Error('unreachable');
|
||||
expect(r.computation.feeBuzz).toBe(32);
|
||||
expect(r.computation.source).toBe('default');
|
||||
expect(mockFeeBuzz).toHaveBeenCalledWith({ coarse_type: 'convert-image' }, 32);
|
||||
});
|
||||
|
||||
it('a throwing counter never propagates to the caller', async () => {
|
||||
mockIsFlipt.mockResolvedValue(true);
|
||||
mockObserved.mockImplementation(() => {
|
||||
throw new Error('registry exploded');
|
||||
});
|
||||
await expect(
|
||||
observeBlockAuthorFee({ baseGenerationBuzz: 137, generationType: 'textToImage' })
|
||||
).resolves.toMatchObject({ observed: true });
|
||||
});
|
||||
});
|
||||
@@ -63,6 +63,19 @@ vi.mock('~/server/logging/client', () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
// The DARK author-fee gate reads Flipt through `app-blocks-flag`. Everything
|
||||
// else in that module stays REAL (a wholesale mock would make the gate's own
|
||||
// wiring untested — see `no-wholesale-module-mock`); only the evaluator is
|
||||
// controlled, so both arms of the gate are reachable from this suite.
|
||||
const mockIsFlipt = vi.hoisted(() => vi.fn());
|
||||
vi.mock('~/server/flipt/client', async (importOriginal) => {
|
||||
// `Record<string, unknown>` rather than `typeof import(…)`: this repo's eslint
|
||||
// forbids `import()` type annotations, and the partial-mock guard accepts
|
||||
// either spelling.
|
||||
const actual = await importOriginal<Record<string, unknown>>();
|
||||
return { ...actual, isFlipt: (...args: unknown[]) => mockIsFlipt(...args) };
|
||||
});
|
||||
|
||||
// Spy on computeSpendShare so the track-only contract is testable: the write
|
||||
// must NEVER call it (the bounty is deferred to payout). Everything else from
|
||||
// rate-card stays real.
|
||||
@@ -162,6 +175,9 @@ beforeEach(() => {
|
||||
refundAppBountySpy.mockReset();
|
||||
mockAppsQuery.mockReset();
|
||||
mockRequireAppsDb.mockReset();
|
||||
// Default: every flag OFF — the as-merged production state for the author fee.
|
||||
mockIsFlipt.mockReset();
|
||||
mockIsFlipt.mockResolvedValue(false);
|
||||
// Default: app exists, owned by a different user than the spender.
|
||||
mockDbRead.oauthClient.findUnique.mockResolvedValue({
|
||||
id: APP_ID,
|
||||
@@ -665,3 +681,245 @@ describe('recordSpendAttribution — generation type', () => {
|
||||
expect(data.generationType).toBe('convert-image');
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// THE AUTHOR-FEE LOG LEDGER.
|
||||
//
|
||||
// A guard on the SET of `authorFee*` fields on the `block-spend-attribution`
|
||||
// Axiom line, not on any one of them. The rule it pins: ONE INSTRUMENT PER
|
||||
// PROPERTY. Three Prometheus counters already carry fee / base / governing-leg
|
||||
// by `coarse_type`, so this line exists only for what those cannot express — a
|
||||
// per-`appBlockId`, per-`isSelfSpend`, per-full-`generationType` cut, plus the
|
||||
// flag-disabled population, which emits no counter at all by design.
|
||||
//
|
||||
// It fails when the set GROWS (a constant like `authorFeeParamsClamped`, or a
|
||||
// duplicate of a counter like `authorFeeLeg`, gets added back) AND when it
|
||||
// SHRINKS (a denominator the sizing read divides by is dropped). A guard on one
|
||||
// field would catch neither direction.
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
describe('recordSpendAttribution — the author-fee log ledger', () => {
|
||||
const AUTHOR_FEE_LOG_FIELDS = [
|
||||
'authorFeeBaseBuzz',
|
||||
'authorFeeBuzz',
|
||||
'authorFeeParamsSource',
|
||||
'authorFeeSkipped',
|
||||
];
|
||||
|
||||
function loggedPayload(): Record<string, unknown> {
|
||||
expect(mockLog).toHaveBeenCalled();
|
||||
return mockLog.mock.calls[0][0] as Record<string, unknown>;
|
||||
}
|
||||
|
||||
it('logs EXACTLY these four author-fee fields — no more, no fewer', async () => {
|
||||
mockIsFlipt.mockResolvedValue(true);
|
||||
await recordSpendAttribution(
|
||||
fakeInput({ generationType: 'convert-image', baseGenerationBuzz: 640 })
|
||||
);
|
||||
expect(
|
||||
Object.keys(loggedPayload())
|
||||
.filter((k) => k.startsWith('authorFee'))
|
||||
.sort()
|
||||
).toEqual(AUTHOR_FEE_LOG_FIELDS);
|
||||
});
|
||||
|
||||
it('the dimensions the counters CANNOT carry are on the same line', async () => {
|
||||
// The whole justification for keeping a per-row fee at all: the counters are
|
||||
// labelled by `coarse_type` only, so this is the only place the fee can be
|
||||
// cut by app or by self-spend.
|
||||
mockIsFlipt.mockResolvedValue(true);
|
||||
await recordSpendAttribution(
|
||||
fakeInput({ generationType: 'convert-image', baseGenerationBuzz: 640 })
|
||||
);
|
||||
const payload = loggedPayload();
|
||||
expect(payload.appBlockId).toBe(APP_BLOCK_ID);
|
||||
expect(payload.isSelfSpend).toBe(false);
|
||||
expect(payload.generationType).toBe('convert-image');
|
||||
});
|
||||
|
||||
it('flag ON, a defaulted type: fee 32 ⚡ off a 640 ⚡ base, source `default`', async () => {
|
||||
// 5% of 640 = 32, by hand from the rule — not read back off the module.
|
||||
mockIsFlipt.mockResolvedValue(true);
|
||||
await recordSpendAttribution(
|
||||
fakeInput({ generationType: 'convert-image', baseGenerationBuzz: 640 })
|
||||
);
|
||||
expect(loggedPayload()).toMatchObject({
|
||||
authorFeeSkipped: null,
|
||||
authorFeeBuzz: 32,
|
||||
authorFeeBaseBuzz: 640,
|
||||
authorFeeParamsSource: 'default',
|
||||
});
|
||||
});
|
||||
|
||||
it('flag ON, a chat-completion at the SAME base: fee 0, source `type`', async () => {
|
||||
// The seeded platform override, observed end-to-end through the service.
|
||||
mockIsFlipt.mockResolvedValue(true);
|
||||
await recordSpendAttribution(
|
||||
fakeInput({ generationType: 'chat-completion', baseGenerationBuzz: 640 })
|
||||
);
|
||||
expect(loggedPayload()).toMatchObject({
|
||||
authorFeeSkipped: null,
|
||||
authorFeeBuzz: 0,
|
||||
authorFeeBaseBuzz: 640,
|
||||
authorFeeParamsSource: 'type',
|
||||
});
|
||||
});
|
||||
|
||||
it('flag OFF (the as-merged state): the row is named as a flag-disabled skip', async () => {
|
||||
// `authorFeeSkipped` is the ONLY instrument for this population — no counter
|
||||
// is emitted on the disabled path — and it is one of the two denominators
|
||||
// the slice-2 sizing read divides by.
|
||||
mockIsFlipt.mockResolvedValue(false);
|
||||
await recordSpendAttribution(
|
||||
fakeInput({ generationType: 'convert-image', baseGenerationBuzz: 640 })
|
||||
);
|
||||
expect(loggedPayload()).toMatchObject({
|
||||
authorFeeSkipped: 'flag-disabled',
|
||||
authorFeeBuzz: null,
|
||||
authorFeeBaseBuzz: null,
|
||||
authorFeeParamsSource: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('flag ON but NO base: its own named skip, never folded into flag-disabled', async () => {
|
||||
mockIsFlipt.mockResolvedValue(true);
|
||||
await recordSpendAttribution(fakeInput({ generationType: 'convert-image' }));
|
||||
expect(loggedPayload()).toMatchObject({
|
||||
authorFeeSkipped: 'base-unavailable',
|
||||
authorFeeBuzz: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('flag ON, a CAP price: `price-is-cap`, and NOT a fee — end to end through the service', async () => {
|
||||
// 🔴 THE SEAM, BEHAVIOURALLY. The structural guard in
|
||||
// `no-divergent-author-fee-base.test.ts` pins that every router call site
|
||||
// THREADS the cap flag; this pins that threading it actually suppresses the
|
||||
// fee, on an input identical to the `fee 32 ⚡` case above except for the
|
||||
// flag. A structural check alone type-checks past a wrong argument.
|
||||
mockIsFlipt.mockResolvedValue(true);
|
||||
await recordSpendAttribution(
|
||||
fakeInput({
|
||||
generationType: 'convert-image',
|
||||
baseGenerationBuzz: 640,
|
||||
generationPriceIsCap: true,
|
||||
})
|
||||
);
|
||||
expect(
|
||||
loggedPayload(),
|
||||
'a cap-priced generation must log a `price-is-cap` skip and NO fee — the same 640 ⚡ base pays 32 ⚡ when the price is final'
|
||||
).toMatchObject({
|
||||
authorFeeSkipped: 'price-is-cap',
|
||||
authorFeeBuzz: null,
|
||||
authorFeeBaseBuzz: null,
|
||||
authorFeeParamsSource: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('a FINAL price at the same base still pays — the cap flag is the only difference', async () => {
|
||||
// The control arm for the test above, in the same file: without it, a change
|
||||
// that suppressed EVERY fee would leave the cap assertion green.
|
||||
mockIsFlipt.mockResolvedValue(true);
|
||||
await recordSpendAttribution(
|
||||
fakeInput({
|
||||
generationType: 'convert-image',
|
||||
baseGenerationBuzz: 640,
|
||||
generationPriceIsCap: false,
|
||||
})
|
||||
);
|
||||
expect(loggedPayload()).toMatchObject({
|
||||
authorFeeSkipped: null,
|
||||
authorFeeBuzz: 32,
|
||||
authorFeeBaseBuzz: 640,
|
||||
});
|
||||
});
|
||||
|
||||
it('reads the dedicated author-fee flag key', async () => {
|
||||
mockIsFlipt.mockResolvedValue(false);
|
||||
await recordSpendAttribution(fakeInput({ baseGenerationBuzz: 640 }));
|
||||
expect(mockIsFlipt).toHaveBeenCalledWith('app-blocks-author-fee-enabled');
|
||||
});
|
||||
|
||||
it('a SELF-SPEND row IS charged a fee — the deliberate divergence from attribution', async () => {
|
||||
// 🔴 THE DIVERGENCE, PINNED. Two lines up in the service, `isSelfSpend` VOIDS
|
||||
// the attribution row and zeroes its share: a bounty is the platform paying
|
||||
// an author out of platform money, so paying them for their own spend is a
|
||||
// wash. The author fee is the VIEWER paying the author, and an author using
|
||||
// their own app is a viewer like any other — so it is charged. That
|
||||
// divergence was argued in ~15 lines of comment and asserted by nothing:
|
||||
// routing self-spend to a null base left every test green (measured at
|
||||
// 92646e0: this file + author-fee.test.ts, 84/84, mutant SURVIVED).
|
||||
mockIsFlipt.mockResolvedValue(true);
|
||||
const res = await recordSpendAttribution(
|
||||
fakeInput({
|
||||
userId: APP_OWNER_USER_ID, // spender == app owner
|
||||
generationType: 'convert-image',
|
||||
baseGenerationBuzz: 640,
|
||||
})
|
||||
);
|
||||
|
||||
// Attribution voids, exactly as before…
|
||||
const { data } = mockDbWrite.blockSpendAttribution.create.mock.calls[0][0];
|
||||
expect(res.written).toBe(true);
|
||||
expect(data.status).toBe('voided');
|
||||
expect(data.voidedReason).toBe('self_spend');
|
||||
expect(data.appOwnerShareCents).toBe(0);
|
||||
|
||||
// …and the AUTHOR FEE is computed anyway. 5% of 640 = 32, by hand from the
|
||||
// rule, not read back off the module.
|
||||
const payload = loggedPayload();
|
||||
expect(payload.isSelfSpend).toBe(true);
|
||||
expect(
|
||||
payload.authorFeeSkipped,
|
||||
'a self-spend generation was NOT charged an author fee — the divergence was removed'
|
||||
).toBeNull();
|
||||
expect(payload.authorFeeBuzz).toBe(32);
|
||||
expect(payload.authorFeeBaseBuzz).toBe(640);
|
||||
expect(payload.authorFeeParamsSource).toBe('default');
|
||||
});
|
||||
|
||||
it('a DUPLICATE (P2002) observes NO second fee — the observation follows the write', async () => {
|
||||
// 🔴 THE ORDERING, PINNED. The row is idempotent on (workflowId, appBlockId);
|
||||
// a re-poll / retry lands in the P2002 branch, and observing the fee BEFORE
|
||||
// the write would count one generation twice — inflating the only number
|
||||
// slice 2 gets to size settlement from by exactly the retry rate. Hoisting
|
||||
// the observation above the create left every test green (measured at
|
||||
// 92646e0: this file + author-fee.test.ts, 84/84, mutant SURVIVED).
|
||||
//
|
||||
// The instrument is the FLAG READ, because it is the first thing
|
||||
// `observeBlockAuthorFee` does and it happens on every path through it,
|
||||
// skipped and observed alike. A count on the log line could not see this:
|
||||
// the log is itself after the write, so the duplicate path emits none either
|
||||
// way.
|
||||
mockIsFlipt.mockResolvedValue(true);
|
||||
const input = fakeInput({ generationType: 'convert-image', baseGenerationBuzz: 640 });
|
||||
|
||||
const first = await recordSpendAttribution(input);
|
||||
expect(first.written).toBe(true);
|
||||
expect(mockIsFlipt).toHaveBeenCalledTimes(1); // the one and only observation
|
||||
|
||||
mockDbWrite.blockSpendAttribution.create.mockRejectedValueOnce(
|
||||
new FakePrismaKnownError('dup', 'P2002')
|
||||
);
|
||||
mockDbRead.blockSpendAttribution.findUnique.mockResolvedValueOnce({
|
||||
id: 'bsa_existing',
|
||||
status: 'tracked',
|
||||
appOwnerShareCents: 0,
|
||||
spendSharePct: 0,
|
||||
grossValueCents: 500,
|
||||
rateCardVersion: 'unrated',
|
||||
voidedReason: null,
|
||||
});
|
||||
|
||||
const second = await recordSpendAttribution(input);
|
||||
expect(second.written).toBe(false);
|
||||
expect(second.row.id).toBe('bsa_existing');
|
||||
// STILL ONE. This is the assertion the hoist breaks.
|
||||
expect(
|
||||
mockIsFlipt.mock.calls.length,
|
||||
'the duplicate observed a SECOND author fee — the observation is no longer after the write'
|
||||
).toBe(1);
|
||||
// …and exactly one row carried a fee to the log.
|
||||
expect(
|
||||
mockLog.mock.calls.filter(([p]) => (p as Record<string, unknown>).authorFeeBuzz != null)
|
||||
).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,686 @@
|
||||
import {
|
||||
blockAuthorFeeBaseBuzzCounter,
|
||||
blockAuthorFeeBuzzCounter,
|
||||
blockAuthorFeeObservedCounter,
|
||||
} from '~/server/prom/client';
|
||||
import { isAppBlocksAuthorFeeEnabled } from '~/server/services/app-blocks-flag';
|
||||
import { blockGenerationCoarseType, isBlockGenerationType } from './generation-type';
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// App Blocks PER-GENERATION AUTHOR FEE — the computation and its configuration.
|
||||
//
|
||||
// WHAT IT IS. An ADDITIVE, AUTHOR-SET, VIEWER-PAID fee on each generation an app
|
||||
// runs. The viewer pays it ON TOP OF the base generation cost; the platform
|
||||
// takes no cut and funds nothing. It replaces the platform-funded percentage
|
||||
// "bounty" that `block_spend_attribution` still records the basis for
|
||||
// (status='tracked' / rate_card_version='unrated' / share=0).
|
||||
//
|
||||
// fee = max(flatBuzz, pctOfBase × base_generation_buzz)
|
||||
//
|
||||
// ── THE `max` COMBINATOR IS OPERATOR-SPECIFIED, NOT DERIVED ─────────────────
|
||||
// Verbatim brief: "make it a 'largest of flat or percent'". Not `flat + pct`,
|
||||
// not a percentage with a floor expressed some other way. Recorded here because
|
||||
// a requirements audit flagged the combinator as UNATTRIBUTED — which it was,
|
||||
// only because the reviewer had not been given this line. It is settled; the
|
||||
// next reader should not re-open it.
|
||||
//
|
||||
// 🔴 THIS IS NOT A RATE CARD, AND DELIBERATELY DOES NOT LIVE IN `rate-card.ts`.
|
||||
// ⚠️ BUT NOT FOR THE REASON AN EARLIER REVISION OF THIS COMMENT GAVE. That
|
||||
// revision argued that a rate card "splits PLATFORM revenue" while this is new
|
||||
// money to the author with no platform share. `RATE_CARD_V4`'s own ACCOUNTING
|
||||
// MODEL note refutes it: the spend bounty a card already carries is "a SEPARATE
|
||||
// platform expense paid ON TOP … NOT a slice carved out of the viewer's money",
|
||||
// and the spend table deliberately omits the purchase table's three-way
|
||||
// conservation CHECK. A card already holds exactly this shape, so "it is not a
|
||||
// split" is not a reason. The two REAL reasons:
|
||||
//
|
||||
// 1. LIFECYCLE — IMMUTABLE PLATFORM SNAPSHOT vs MUTABLE PER-APP SETTING. A
|
||||
// `RateCard` is "NEVER mutated in place"; changing a number means a new
|
||||
// version constant, and a row stamps `rate_card_version` at WRITE TIME and
|
||||
// "pays out under its own snapshot for the lifetime of the row". There is
|
||||
// one `ACTIVE_RATE_CARD` for the whole platform. This fee is PER-APP,
|
||||
// AUTHOR-SET and MUTABLE: in slice 3 an author edits it and the very next
|
||||
// generation charges the new number. One card per app is not ugly, it is
|
||||
// structurally impossible — the version string on a row names a
|
||||
// platform-wide document, not an app's current setting.
|
||||
//
|
||||
// 2. UNITS — PERCENT-OF-USD-CENTS vs BUZZ INTEGERS. Every card field is a
|
||||
// percentage applied to CENTS (`publisherSharePctByScope` is a % of
|
||||
// `gross_cents - provider_fee_cents`; `spendSharePct` is a % of the
|
||||
// spend's USD value), and a flat BUZZ leg has no expression in that unit at
|
||||
// all. Worse, the card's per-row CENT FLOORING is precisely the defect that
|
||||
// made the bounty pay $0.00: at 10 Buzz per cent (`buzzSpendToUsdCents`)
|
||||
// and `spendSharePct: 5`, `computeSpendShare` returns **0 cents for every
|
||||
// generation under 200 ⚡** — i.e. for most of them. Computing in Buzz and
|
||||
// flooring ONCE, at the end, is what the basis-point arithmetic below
|
||||
// exists for.
|
||||
//
|
||||
// ── SLICE 1 IS DARK. IT COMPUTES AND OBSERVES; IT MOVES NO MONEY. ───────────
|
||||
// Settlement onto the licensing-fee rail is slice 2; the author-facing config
|
||||
// UI and the viewer-facing disclosure are slice 3. Nothing here writes a row,
|
||||
// reads a row, or touches a Buzz account. `observeBlockAuthorFee` is the ONLY
|
||||
// production entry point and it is fail-closed behind
|
||||
// `app-blocks-author-fee-enabled`.
|
||||
//
|
||||
// ── NO MIGRATION IN THIS SLICE, ON PURPOSE ──────────────────────────────────
|
||||
// The defaults apply to EVERY app including the ones that already exist, so
|
||||
// ABSENCE OF PER-APP CONFIGURATION MEANS THE DEFAULT APPLIES and there is
|
||||
// nothing to back-fill. Per-app storage only becomes necessary when an author
|
||||
// can edit it, which is slice 3. Until then the platform defaults and the
|
||||
// per-type table are code constants here. Every migration on this database is
|
||||
// applied BY HAND, PER ENVIRONMENT, so a table nobody can write to yet is pure
|
||||
// cost.
|
||||
//
|
||||
// ── THE FEE STACKS ──────────────────────────────────────────────────────────
|
||||
// It sits alongside, never instead of, the model licensing fee and the lineage
|
||||
// fee already on a generation. Each party charges for its own contribution; the
|
||||
// orchestrator already charges the sum of a `fees[]` array and settles each
|
||||
// entry separately (see `src/pages/api/v1/model-versions/mini/[id].ts`). Slice 1
|
||||
// computes this app's own entry and stops there.
|
||||
//
|
||||
// 🔴 ── `baseGenerationBuzz` IS `WorkflowCost.base`, NOT `WorkflowCost.total` ──
|
||||
// MEASURED, not assumed. The orchestrator's `WorkflowCost` is
|
||||
// `{ base, factors, fixed, tips, fees, total }` where `fees` is the per-resource
|
||||
// LICENSING fee map keyed by resource AIR and `total` INCLUDES both it and the
|
||||
// tips (`training.orch.ts` sums `cost.fees` precisely to break the licensing
|
||||
// component back out of `cost.total`). The spend-attribution row's `buzzAmount`
|
||||
// is derived from the realized paid debit, i.e. a gross that ALREADY CARRIES the
|
||||
// licensing fee, the lineage fee and the tips.
|
||||
//
|
||||
// So `buzzAmount` is the WRONG input. Charging a percentage of it would take a
|
||||
// percentage of another creator's licensing fee and of the viewer's tip, and
|
||||
// would compound as more fee-charging resources are stacked onto one
|
||||
// generation. The caller must pass `cost.base`. This module cannot detect the
|
||||
// mistake — the two are both plain positive numbers — which is why it is stated
|
||||
// here and asserted at the one call site rather than left to a reviewer.
|
||||
//
|
||||
// (Residual uncertainty, recorded rather than guessed: the orchestrator's own
|
||||
// docs describe `base` only as "the base cost of this request, excluding any
|
||||
// tips" and do not say whether `factors`/`fixed` are folded into it. Nothing on
|
||||
// the App Blocks path reads either field today. If slice 2 needs that
|
||||
// distinction it has to be settled against the orchestrator, not inferred here.)
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Platform default flat leg: 1 ⚡ per generation. */
|
||||
export const BLOCK_AUTHOR_FEE_DEFAULT_FLAT_BUZZ = 1;
|
||||
|
||||
/** Platform default percentage leg: 5% of the BASE generation Buzz. */
|
||||
export const BLOCK_AUTHOR_FEE_DEFAULT_PCT_OF_BASE = 0.05;
|
||||
|
||||
/**
|
||||
* Platform CEILING on the flat leg. There is deliberately NO floor: an author
|
||||
* may set either leg to 0, and 0/0 is a valid configuration meaning "this app
|
||||
* charges nothing on this generation type" (Justin's motivating case: nothing on
|
||||
* chat completions, the default on everything else).
|
||||
*/
|
||||
export const BLOCK_AUTHOR_FEE_MAX_FLAT_BUZZ = 100;
|
||||
|
||||
/**
|
||||
* Platform CEILING on the percentage leg: 100% of base. Again, no floor.
|
||||
*
|
||||
* 🔴 THIS IS THE ONE SPELLING OF THE CEILING. `BLOCK_AUTHOR_FEE_MAX_PCT_BASIS_POINTS`
|
||||
* below is DERIVED from it, and the clamp enforces that derived value — so the
|
||||
* policy an author-facing validator reads (slice 3) and the bound the
|
||||
* computation enforces cannot disagree. Until this was derived they were two
|
||||
* independent numbers that agreed only by coincidence: the clamp capped at
|
||||
* `BLOCK_AUTHOR_FEE_BASIS_POINTS_SCALE`, which was doing double duty as scale
|
||||
* factor AND ceiling, and this constant had no implementation reader at all.
|
||||
*/
|
||||
export const BLOCK_AUTHOR_FEE_MAX_PCT_OF_BASE = 1;
|
||||
|
||||
/**
|
||||
* Percentage resolution. The percentage leg is evaluated in BASIS POINTS
|
||||
* (1 bp = 0.01%) rather than as a float multiply, so the arithmetic is exact
|
||||
* integer arithmetic and the crossover lands where it is supposed to:
|
||||
* `floor(20 × 500 / 10000) === 1`, not `floor(20 × 0.05)` with whatever the
|
||||
* double rounds to. A fee percentage finer than one basis point is not a
|
||||
* quantity anyone can act on, so quantizing at the clamp costs nothing.
|
||||
*
|
||||
* ⚠️ SCALE FACTOR ONLY. It is NOT the ceiling — see the constant below.
|
||||
*/
|
||||
export const BLOCK_AUTHOR_FEE_BASIS_POINTS_SCALE = 10_000;
|
||||
|
||||
/**
|
||||
* The ceiling's derivation, as a FUNCTION of the declared policy rather than an
|
||||
* expression inlined into the constant below.
|
||||
*
|
||||
* 🔴 IT QUANTIZES WITH `toBasisPoints`, i.e. IT FLOORS. It used to be
|
||||
* `Math.round(BLOCK_AUTHOR_FEE_MAX_PCT_OF_BASE * SCALE)`, which rounds the
|
||||
* OPPOSITE way to the quantization it governs: `toBasisPoints` floors precisely
|
||||
* because "every rounding in this module goes toward the viewer", and a rounded
|
||||
* ceiling lands ABOVE the declared policy — a declared 12.3456% deriving to
|
||||
* 1235 bp = 12.35% — the one direction this module says it never rounds.
|
||||
*
|
||||
* Inert at today's `BLOCK_AUTHOR_FEE_MAX_PCT_OF_BASE = 1`, where floor and round
|
||||
* are both 10000; live the moment slice 3 or a policy change sets a ceiling
|
||||
* finer than one basis point.
|
||||
*
|
||||
* ⚠️ DO NOT INLINE IT BACK INTO THE CONSTANT. It is a function so the rounding
|
||||
* DIRECTION is reachable from a test at a ceiling the policy constant cannot
|
||||
* express — at `1` no assertion about the constant can tell floor from round, so
|
||||
* inlining makes the defect unkillable again rather than merely dormant.
|
||||
*
|
||||
* That is ENFORCED, not merely requested: `author-fee.test.ts` reads this file's
|
||||
* source and fails if the constant below is initialised by anything other than a
|
||||
* call to this function. Until it did, re-inlining `Math.round(…)` here survived
|
||||
* the entire suite — every behavioural pin agrees while floor and round agree.
|
||||
*/
|
||||
export function blockAuthorFeeCeilingBasisPoints(maxPctOfBase: number): number {
|
||||
return toBasisPoints(maxPctOfBase);
|
||||
}
|
||||
|
||||
/**
|
||||
* The percentage ceiling in the unit the clamp computes in. DERIVED, never
|
||||
* written by hand: this is `BLOCK_AUTHOR_FEE_MAX_PCT_OF_BASE` expressed in basis
|
||||
* points, so moving the declared policy moves the enforced bound with it — and
|
||||
* quantized by the SAME floor the clamp applies to an author's percentage, so
|
||||
* the enforced bound can never sit above the policy it is enforcing.
|
||||
*/
|
||||
export const BLOCK_AUTHOR_FEE_MAX_PCT_BASIS_POINTS = blockAuthorFeeCeilingBasisPoints(
|
||||
BLOCK_AUTHOR_FEE_MAX_PCT_OF_BASE
|
||||
);
|
||||
|
||||
/** The `coarse_type` metric label used when the generation type is unresolvable. */
|
||||
export const BLOCK_AUTHOR_FEE_UNKNOWN_TYPE_LABEL = 'unknown';
|
||||
|
||||
/** One (flat, percent) pair. Both legs are settable from 0 upward. */
|
||||
export type BlockAuthorFeeParams = {
|
||||
/** Flat Buzz leg. `>= 0`, capped at `BLOCK_AUTHOR_FEE_MAX_FLAT_BUZZ`. */
|
||||
readonly flatBuzz: number;
|
||||
/** Fraction of the base generation Buzz — `0.05` is 5%. `>= 0`, capped at 1. */
|
||||
readonly pctOfBase: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* A per-generation-type override.
|
||||
*
|
||||
* 🔴 AN ARRAY OF PAIRS, NOT AN OBJECT MAP — the same safety property, for the
|
||||
* same reason, as `IMAGE_SUBTYPE_BY_WORKFLOW` in `generation-type.ts`. The key
|
||||
* side is a generation type arriving from a persisted column, and indexing an
|
||||
* object literal with such a key fails OPEN (`({} as any)['toString']` is
|
||||
* truthy, and a `Partial<Record<…>>` lookup would hand back
|
||||
* `Function.prototype.toString` as though it were a fee configuration). A
|
||||
* `.find` over a tuple array has no prototype to fall through to. It also keeps
|
||||
* ordering explicit, which matters because a full-type entry must be able to
|
||||
* beat a coarse-type one.
|
||||
*
|
||||
* The key is EITHER a full generation type (`textToImage:img2img-edit`,
|
||||
* `customComfy:inline`) or a COARSE key (`textToImage`, `chat-completion`).
|
||||
* Which one it is decides nothing at declaration time — `resolveBlockAuthorFeeParams`
|
||||
* tries the full value first and the coarse key second, so the same table holds
|
||||
* both without a discriminator.
|
||||
*/
|
||||
export type BlockAuthorFeeTypeOverride = readonly [type: string, params: BlockAuthorFeeParams];
|
||||
|
||||
/** An app's fee configuration: a default, plus optional per-type overrides. */
|
||||
export type BlockAuthorFeeConfig = {
|
||||
readonly default: BlockAuthorFeeParams;
|
||||
readonly byType?: readonly BlockAuthorFeeTypeOverride[];
|
||||
};
|
||||
|
||||
/** The platform default pair — what every app charges until an author changes it. */
|
||||
export const BLOCK_AUTHOR_FEE_DEFAULT_PARAMS: BlockAuthorFeeParams = {
|
||||
flatBuzz: BLOCK_AUTHOR_FEE_DEFAULT_FLAT_BUZZ,
|
||||
pctOfBase: BLOCK_AUTHOR_FEE_DEFAULT_PCT_OF_BASE,
|
||||
};
|
||||
|
||||
/**
|
||||
* The PLATFORM configuration — what slice 1 uses for every app, including the
|
||||
* ones that already exist.
|
||||
*
|
||||
* `chat-completion` → 0/0, i.e. NO FEE. This is Justin's motivating example
|
||||
* ("nothing on chat completions") implemented as a PLATFORM DEFAULT rather than
|
||||
* left for each author to discover in slice 3. Chat completion is the
|
||||
* highest-frequency generation type an app runs — a conversational block bills
|
||||
* one per turn — so a 1 ⚡ flat floor on each is a per-message toll rather than
|
||||
* a fee on a generation. The platform charges nothing there until an author
|
||||
* says otherwise.
|
||||
*
|
||||
* 🔴 THE TABLE BEING NON-EMPTY IS WHAT MAKES THE RESOLVER LIVE IN PRODUCTION.
|
||||
* An earlier revision shipped `byType: []` with a comment calling the empty
|
||||
* table "the resolver's real, exercised input". It was not: with no entries
|
||||
* EVERY production lookup fell to the default, the `type` and `coarse` arms of
|
||||
* `resolveBlockAuthorFeeParams` were unreachable outside its own unit tests,
|
||||
* and `source` was a compile-time constant `'default'`. A precedence rule that
|
||||
* production never executes is not configuration, it is dead code with a test.
|
||||
*/
|
||||
export const BLOCK_AUTHOR_FEE_PLATFORM_CONFIG: BlockAuthorFeeConfig = {
|
||||
default: BLOCK_AUTHOR_FEE_DEFAULT_PARAMS,
|
||||
byType: [['chat-completion', { flatBuzz: 0, pctOfBase: 0 }]],
|
||||
};
|
||||
|
||||
/** Params after the platform ceiling has been applied, in computable units. */
|
||||
export type ClampedBlockAuthorFeeParams = {
|
||||
/** Integer Buzz in `[0, BLOCK_AUTHOR_FEE_MAX_FLAT_BUZZ]`. */
|
||||
readonly flatBuzz: number;
|
||||
/** Integer basis points in `[0, BLOCK_AUTHOR_FEE_MAX_PCT_BASIS_POINTS]`. */
|
||||
readonly pctBasisPoints: number;
|
||||
/** True when either leg was out of range (or unusable) and had to be pulled in. */
|
||||
readonly clamped: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* A finite fraction → whole basis points, rounding DOWN.
|
||||
*
|
||||
* 🔴 IT FLOORS, IT DOES NOT ROUND. `computeBlockAuthorFee` promises that "a
|
||||
* stated 5% never charges more than 5%", and `Math.round` breaks that promise at
|
||||
* the quantization step before the fee is ever computed: a stated `0.049999`
|
||||
* rounds UP to 500 bp and charges a full 5%. Flooring makes the guarantee exact
|
||||
* — 499 bp — and keeps every rounding in this module pointed the same way, at
|
||||
* the viewer.
|
||||
*
|
||||
* ⚠️ THE `toFixed` IS NOT DECORATION — a naive `Math.floor(pct * SCALE)` LOSES A
|
||||
* BASIS POINT ON 573 OF THE 10,001 EXACT BASIS-POINT INPUTS (measured), because
|
||||
* a value like `0.0029` is not exactly representable and `0.0029 * 10_000` lands
|
||||
* at `28.999999999999996`. An author typing 0.29% would be charged 0.28%.
|
||||
* Normalising to 6 decimal places first — far finer than one basis point, so it
|
||||
* cannot mask a genuine sub-bp fraction — makes every exact basis-point input
|
||||
* exact (measured: 0 of 10,001 wrong) while still flooring `0.049999` to 499.
|
||||
*
|
||||
* TWO CALLERS, not one: the clamp below AND `blockAuthorFeeCeilingBasisPoints`,
|
||||
* which derives `BLOCK_AUTHOR_FEE_MAX_PCT_BASIS_POINTS`. Changing the direction
|
||||
* here moves the enforced ceiling with it, on purpose — that shared spelling is
|
||||
* what keeps the bound from sitting above the policy it enforces.
|
||||
*/
|
||||
function toBasisPoints(pct: number): number {
|
||||
return Math.floor(Number((pct * BLOCK_AUTHOR_FEE_BASIS_POINTS_SCALE).toFixed(6)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply the platform CEILING to a pair, and quantize the percentage leg to
|
||||
* basis points.
|
||||
*
|
||||
* The percentage ceiling enforced here is `BLOCK_AUTHOR_FEE_MAX_PCT_BASIS_POINTS`,
|
||||
* which is DERIVED from `BLOCK_AUTHOR_FEE_MAX_PCT_OF_BASE` — so the number an
|
||||
* author-facing validator reads and the number this enforces are one value, not
|
||||
* two that happen to agree.
|
||||
*
|
||||
* Clamps rather than rejects. Slice 1's only config source is a code constant,
|
||||
* but slice 3's is author input, and a fee path that throws on a bad number is a
|
||||
* fee path that can take down a generation submit. Clamping is also the correct
|
||||
* answer in its own right: the ceiling is the platform's, so a configuration
|
||||
* above it is not an error to report back, it is a number the platform declines
|
||||
* to honour. A non-finite or missing leg collapses to 0 — the safe direction,
|
||||
* since 0 charges the viewer nothing.
|
||||
*
|
||||
* `clamped` is reported (and surfaced as a log field) rather than swallowed, so
|
||||
* a configuration that is silently not doing what its author asked is visible.
|
||||
*/
|
||||
export function clampBlockAuthorFeeParams(
|
||||
params: BlockAuthorFeeParams
|
||||
): ClampedBlockAuthorFeeParams {
|
||||
const rawFlat = params.flatBuzz;
|
||||
const flatUsable = typeof rawFlat === 'number' && Number.isFinite(rawFlat);
|
||||
const flatFloor = flatUsable ? Math.floor(rawFlat) : 0;
|
||||
const flatBuzz = Math.min(Math.max(flatFloor, 0), BLOCK_AUTHOR_FEE_MAX_FLAT_BUZZ);
|
||||
|
||||
const rawPct = params.pctOfBase;
|
||||
const pctUsable = typeof rawPct === 'number' && Number.isFinite(rawPct);
|
||||
const rawBasisPoints = pctUsable ? toBasisPoints(rawPct) : 0;
|
||||
const pctBasisPoints = Math.min(
|
||||
Math.max(rawBasisPoints, 0),
|
||||
BLOCK_AUTHOR_FEE_MAX_PCT_BASIS_POINTS
|
||||
);
|
||||
|
||||
// Quantizing 0.050001 to 500 bp is NOT a clamp — only leaving the permitted
|
||||
// range is, plus a leg that was not a usable number to begin with.
|
||||
const clamped =
|
||||
!flatUsable || !pctUsable || flatBuzz !== flatFloor || pctBasisPoints !== rawBasisPoints;
|
||||
|
||||
return { flatBuzz, pctBasisPoints, clamped };
|
||||
}
|
||||
|
||||
/** Which level of the config answered the lookup. */
|
||||
export type BlockAuthorFeeParamsSource = 'type' | 'coarse' | 'default';
|
||||
|
||||
export type BlockAuthorFeeParamsResolution = {
|
||||
readonly params: BlockAuthorFeeParams;
|
||||
readonly source: BlockAuthorFeeParamsSource;
|
||||
/** The coarse key the fee was looked up under, or `null` if unresolvable. */
|
||||
readonly coarseType: string | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Resolve the (flat, percent) pair for one generation type.
|
||||
*
|
||||
* PRECEDENCE, most specific first:
|
||||
* 1. an override whose key is the FULL generation type (`textToImage:img2img`)
|
||||
* 2. an override whose key is its COARSE key (`textToImage`)
|
||||
* 3. the config default
|
||||
*
|
||||
* The parameters are "per generation type" at the COARSE level — that is the key
|
||||
* `blockGenerationCoarseType` exists to produce and the level Justin's example
|
||||
* operates at ("nothing on chat completions") — with the full-type override
|
||||
* available for a case that needs to split one coarse key. For a value that
|
||||
* carries no subtype the two keys are the same string, so an entry for it
|
||||
* reports `source: 'type'`; that is a labelling detail, not a different answer.
|
||||
*
|
||||
* 🔴 THE LOOKUP KEY IS BOUNDED BEFORE IT IS USED. `generationType` arrives as
|
||||
* `unknown` (it is read off a nullable column), and `isBlockGenerationType` is
|
||||
* the one place that says what a legal value is. An unrecognised value —
|
||||
* including `null`, a typo, and a value from a build with a wider registry —
|
||||
* resolves to the DEFAULT rather than to no fee: the defaults apply to
|
||||
* everything, and a generation we could not type is still a generation. That is
|
||||
* also why no prototype-key guard is written here. With the key bounded to the
|
||||
* registry-derived sets, a string like `'toString'` can never reach the `.find`,
|
||||
* so such a guard would be unreachable — and an unreachable guard reads as
|
||||
* coverage while providing none.
|
||||
*
|
||||
* ⚠️ CONSEQUENCE WORTH KNOWING BEFORE SLICE 3: an author who zeroes the fee for
|
||||
* one type is NOT protected by that setting on a generation whose type failed to
|
||||
* resolve — it falls to their default. Resolution failure is rare and already
|
||||
* visible (`generation_type` is NULL on the row, and the observation counter
|
||||
* carries `coarse_type="unknown"`), but the direction of the fallback is a
|
||||
* decision, and this is it.
|
||||
*/
|
||||
export function resolveBlockAuthorFeeParams(
|
||||
config: BlockAuthorFeeConfig,
|
||||
generationType: unknown
|
||||
): BlockAuthorFeeParamsResolution {
|
||||
const overrides = config.byType ?? [];
|
||||
const knownType = isBlockGenerationType(generationType) ? generationType : null;
|
||||
const coarseType = blockGenerationCoarseType(knownType);
|
||||
|
||||
if (knownType !== null) {
|
||||
const exact = overrides.find(([key]) => key === knownType);
|
||||
if (exact) return { params: exact[1], source: 'type', coarseType };
|
||||
|
||||
if (coarseType !== null) {
|
||||
const coarse = overrides.find(([key]) => key === coarseType);
|
||||
if (coarse) return { params: coarse[1], source: 'coarse', coarseType };
|
||||
}
|
||||
}
|
||||
|
||||
return { params: config.default, source: 'default', coarseType };
|
||||
}
|
||||
|
||||
/** Which leg of `max(flat, pct)` decided the fee. `'none'` iff the fee is 0. */
|
||||
export type BlockAuthorFeeLeg = 'flat' | 'pct' | 'none';
|
||||
|
||||
export type BlockAuthorFeeComputation = {
|
||||
/** The fee in Buzz. Always `Math.max(flatLegBuzz, pctLegBuzz)`. */
|
||||
readonly feeBuzz: number;
|
||||
/** The normalized base the percentage leg was taken of. */
|
||||
readonly baseGenerationBuzz: number;
|
||||
readonly flatLegBuzz: number;
|
||||
readonly pctLegBuzz: number;
|
||||
readonly governingLeg: BlockAuthorFeeLeg;
|
||||
readonly source: BlockAuthorFeeParamsSource;
|
||||
readonly coarseType: string | null;
|
||||
readonly clamped: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* `fee = max(flatBuzz, pctOfBase × base_generation_buzz)`, in whole Buzz.
|
||||
*
|
||||
* 🔴 ZERO BASE ⇒ ZERO FEE, AND IT IS A SEPARATE RULE FROM THE FORMULA. A plain
|
||||
* `max(1, 5% × 0)` is 1, so the flat leg would MINT a fee out of a generation
|
||||
* that cost nothing. That is not a hypothetical: zero-base generations happen
|
||||
* (17 of 600 measured events), and charging for one is the single most
|
||||
* indefensible thing this computation could do. The guard is stated first and
|
||||
* returns before the legs are evaluated.
|
||||
*
|
||||
* The percentage leg FLOORS — at BOTH steps, which is what makes the guarantee
|
||||
* exact rather than approximate: the stated fraction floors to whole basis
|
||||
* points in `toBasisPoints`, and the resulting Buzz floors again here. Buzz is an
|
||||
* integer currency and the fee is additive on top of what the viewer already
|
||||
* pays, so every rounding goes toward the viewer: a stated 5% never charges more
|
||||
* than 5%. The flat leg is the floor of the whole expression, which is what makes
|
||||
* the default meaningful on a cheap generation.
|
||||
*
|
||||
* At the CROSSOVER the legs are equal and `governingLeg` reports `'flat'` — an
|
||||
* arbitrary but pinned tie-break, chosen so the leg that is always present wins
|
||||
* and a rounding wobble cannot flip the reported label back and forth.
|
||||
*
|
||||
* Total and non-throwing. `baseGenerationBuzz` is typed `unknown` because the
|
||||
* only production caller reads it off an optional orchestrator response field;
|
||||
* anything that is not a finite positive number is treated as a zero base.
|
||||
*/
|
||||
export function computeBlockAuthorFee(args: {
|
||||
/** 🔴 The BASE generation cost — `WorkflowCost.base`, never `.total`. */
|
||||
baseGenerationBuzz: unknown;
|
||||
generationType: unknown;
|
||||
config?: BlockAuthorFeeConfig;
|
||||
}): BlockAuthorFeeComputation {
|
||||
const config = args.config ?? BLOCK_AUTHOR_FEE_PLATFORM_CONFIG;
|
||||
const { params, source, coarseType } = resolveBlockAuthorFeeParams(config, args.generationType);
|
||||
const { flatBuzz, pctBasisPoints, clamped } = clampBlockAuthorFeeParams(params);
|
||||
|
||||
const rawBase = args.baseGenerationBuzz;
|
||||
const baseGenerationBuzz =
|
||||
typeof rawBase === 'number' && Number.isFinite(rawBase) && rawBase > 0
|
||||
? Math.floor(rawBase)
|
||||
: 0;
|
||||
|
||||
if (baseGenerationBuzz === 0) {
|
||||
// Both legs report 0 rather than the configured flat value, so the invariant
|
||||
// `feeBuzz === max(flatLegBuzz, pctLegBuzz)` holds on every branch and a
|
||||
// reader of the log line cannot mistake an uncharged flat leg for a charged one.
|
||||
return {
|
||||
feeBuzz: 0,
|
||||
baseGenerationBuzz: 0,
|
||||
flatLegBuzz: 0,
|
||||
pctLegBuzz: 0,
|
||||
governingLeg: 'none',
|
||||
source,
|
||||
coarseType,
|
||||
clamped,
|
||||
};
|
||||
}
|
||||
|
||||
const pctLegBuzz = Math.floor(
|
||||
(baseGenerationBuzz * pctBasisPoints) / BLOCK_AUTHOR_FEE_BASIS_POINTS_SCALE
|
||||
);
|
||||
const flatLegBuzz = flatBuzz;
|
||||
const feeBuzz = Math.max(flatLegBuzz, pctLegBuzz);
|
||||
|
||||
return {
|
||||
feeBuzz,
|
||||
baseGenerationBuzz,
|
||||
flatLegBuzz,
|
||||
pctLegBuzz,
|
||||
governingLeg: feeBuzz === 0 ? 'none' : pctLegBuzz > flatLegBuzz ? 'pct' : 'flat',
|
||||
source,
|
||||
coarseType,
|
||||
clamped,
|
||||
};
|
||||
}
|
||||
|
||||
/** Why an observation produced no computation. */
|
||||
export type BlockAuthorFeeSkipReason = 'flag-disabled' | 'base-unavailable' | 'price-is-cap';
|
||||
|
||||
/**
|
||||
* ONE SPELLING of the missing-base skip, shared by the Prometheus `outcome`
|
||||
* label and the Axiom `authorFeeSkipped` field. The counter used to say
|
||||
* `base_unavailable` while the log line said `base-unavailable` — two spellings
|
||||
* of one concept across the two instruments the slice-2 sizing read joins, which
|
||||
* is exactly the join that then silently returns nothing.
|
||||
*/
|
||||
export const BLOCK_AUTHOR_FEE_BASE_UNAVAILABLE: BlockAuthorFeeSkipReason = 'base-unavailable';
|
||||
|
||||
/**
|
||||
* The CAP-PRICED skip — a THIRD reason, deliberately not either of the other two.
|
||||
*
|
||||
* 🔴 WHY IT IS ITS OWN REASON RATHER THAN A `base-unavailable`. The base IS
|
||||
* available on this path; what is provisional is the PRICE. `base-unavailable`
|
||||
* is one of the two denominators the slice-2 sizing read divides by ("how many
|
||||
* generations did the fee never get to see, and could we recover them?"), and a
|
||||
* cap-priced generation is not recoverable by surfacing a better base — it is a
|
||||
* policy question. Folding a different cause into that population is how a
|
||||
* denominator acquires a silent bias, which is the same rule the `.catch`
|
||||
* discussion in `buzz-attribution.service` states for a contract violation.
|
||||
*
|
||||
* 🔴 WHY THE FEE IS SKIPPED AT ALL. `WorkflowCost.variable` means "this price is
|
||||
* a cap that may settle lower: at least one step is post-billed and charged up
|
||||
* front at its maximum, with the difference refunded once the provider reports
|
||||
* the actual work delivered". The viewer is charged the maximum up front and
|
||||
* refunded down later, so a percentage of that number is a fee on money they did
|
||||
* not ultimately spend, and the flat leg is a toll on a job that may have done
|
||||
* almost nothing. Slice 1 moves no money, so today this only skews the sizing
|
||||
* read — but the sizing read is the entire purpose of slice 1, and slice 2 would
|
||||
* inherit the decision silently if it were left implicit.
|
||||
*
|
||||
* ⚠️ FLAGGED FOR SLICE 2: "no fee on a cap" is the CURRENT answer, made explicit
|
||||
* and testable, NOT a settled policy. The proper treatment (charge on the
|
||||
* settled cost at terminal state, charge on the cap and refund pro rata, or
|
||||
* genuinely charge nothing) is slice 2's to decide — and it now has a counted
|
||||
* population to decide it against instead of an unlabelled blind spot.
|
||||
*/
|
||||
export const BLOCK_AUTHOR_FEE_PRICE_IS_CAP: BlockAuthorFeeSkipReason = 'price-is-cap';
|
||||
|
||||
export type BlockAuthorFeeObservation =
|
||||
| { readonly observed: false; readonly reason: BlockAuthorFeeSkipReason }
|
||||
| { readonly observed: true; readonly computation: BlockAuthorFeeComputation };
|
||||
|
||||
/**
|
||||
* The ONE production entry point, and the dark gate.
|
||||
*
|
||||
* 🔴 FAIL-CLOSED AND FIRST. The flag is read before anything else happens —
|
||||
* before the base is inspected and before any parameter is resolved. With
|
||||
* `app-blocks-author-fee-enabled` off, absent, or Flipt unreachable, `isFlipt`
|
||||
* answers `false` and this returns immediately, so the computation is
|
||||
* unreachable from every production path and emits no signal at all.
|
||||
*
|
||||
* ⚠️ THE FLAG EXISTS IN FLIPT, AT BASE `enabled: false`. An earlier revision of
|
||||
* this comment said it does NOT exist and that this is what makes the as-merged
|
||||
* behaviour dark. That is no longer true: it was created after this branch's
|
||||
* last commit, deliberately, because an ABSENT key makes the evaluation throw,
|
||||
* bypass its cache and write a `console.error` on every App Blocks generation
|
||||
* submit, indefinitely. Verified live in the `civitai-app` environment:
|
||||
* `BOOLEAN_FLAG_TYPE`, `enabled: false`, no variants, no rules, no rollouts, and
|
||||
* a global boolean evaluation returning
|
||||
* `enabled:false, reason:DEFAULT_EVALUATION_REASON, segmentKeys:[]`.
|
||||
*
|
||||
* The conclusion survives — as-merged behaviour is dark — but the REASON, and
|
||||
* the strength of it, do not. An absent key had to be CREATED by an operator
|
||||
* before anyone could turn the fee on. A present base-`false` flag is one toggle
|
||||
* away, with no deploy and no review. So this is dark because the flag is OFF,
|
||||
* not because turning it on takes a second step. Slice 2 must not treat the
|
||||
* off-state as structural.
|
||||
*
|
||||
* ⚠️ AN EARLIER REVISION ALSO CLAIMED THE FLAG IS READ "before the telemetry
|
||||
* module is even imported", and used `await import()` for both dependencies to
|
||||
* make that true. IT WAS NOT TRUE AND THE INDIRECTION DEFERRED NOTHING: the
|
||||
* sole caller `buzz-attribution.service` STATICALLY imports
|
||||
* `~/server/prom/client`, and `blocks.router` — which imports that service —
|
||||
* statically imports `app-blocks-flag`. Both modules are already in the module
|
||||
* cache before this function is entered, so the dynamic form bought no
|
||||
* deferral, and the `.catch(() => ({ …: null }))` fallback it carried was
|
||||
* unreachable. Both are ordinary static imports now and the claim is deleted
|
||||
* rather than reworded.
|
||||
*
|
||||
* 🔴 WHAT PINS THE ORDER, EXACTLY. Two different assertions, because one of them
|
||||
* does less than it reads like it does:
|
||||
* - the counter assertions pin only that NOTHING IS EMITTED on the disabled
|
||||
* path. `computeBlockAuthorFee` is pure, so a copy of it hoisted above the
|
||||
* flag read emits nothing either and those assertions stay green — they are
|
||||
* not an ordering guard and must not be read as one.
|
||||
* - the ordering itself is pinned by `does not touch its own ARGUMENTS`, which
|
||||
* hands this function an args object whose `generationType` and `config` are
|
||||
* GETTERS. Neither is read anywhere but inside the `computeBlockAuthorFee`
|
||||
* call below, so a read with the flag off means the computation ran early.
|
||||
* Slice 2 replaces "pure computation" with "moves money", at which point
|
||||
* this is the guard that matters.
|
||||
*
|
||||
* OPERATOR NOTE: `app-blocks-author-fee-enabled` EXISTS as a PLAIN GLOBAL
|
||||
* BOOLEAN with no segment — keep it that way. This evaluates globally (entityId
|
||||
* `'global'`, empty context), so no segment can ever match and the answer is
|
||||
* always the flag's BASE value — a base-`false` flag decorated with a rollout
|
||||
* stays dark for everyone, and (the non-fail-safe direction) a base-`true` flag
|
||||
* decorated with one is ON for everyone. Set the base, do not decorate it.
|
||||
*
|
||||
* MAKES THE FEE OBSERVABLE WITHOUT STORING IT. Slice 2 has to be sized from real
|
||||
* traffic before anyone is charged, and nothing here persists a number: the
|
||||
* computation goes to the same two places the spend attribution already reports
|
||||
* to — three Prometheus counters and the `block-spend-attribution` Axiom line.
|
||||
* `block_author_fee_buzz_total / block_author_fee_base_buzz_total` by coarse
|
||||
* type gives the realized effective rate; `block_author_fee_observed_total` by
|
||||
* `outcome` gives the leg mix plus the `base-unavailable` and `price-is-cap`
|
||||
* populations — in the SAME spelling the log line's `authorFeeSkipped` uses, so
|
||||
* the two instruments join. The OTHER skip — the flag being off — is silent here and
|
||||
* visible only as `authorFeeSkipped` on the log line, because a gate that has
|
||||
* never been turned on must not emit a per-generation metric.
|
||||
*
|
||||
* TOTAL AND NON-THROWING. Every caller is on a fire-and-forget path off an
|
||||
* already-billed submit. A telemetry failure, a flag-read failure, or anything
|
||||
* else degrades to a skip — this must never become a new way for the spend path
|
||||
* to throw.
|
||||
*/
|
||||
export async function observeBlockAuthorFee(args: {
|
||||
/** 🔴 `WorkflowCost.base`. Absent/unusable → a `base-unavailable` skip. */
|
||||
baseGenerationBuzz: number | null | undefined;
|
||||
/**
|
||||
* 🔴 `WorkflowCost.variable` — TRUE means the quoted price is a CAP that may
|
||||
* settle lower. `true` → a `price-is-cap` skip; see
|
||||
* `BLOCK_AUTHOR_FEE_PRICE_IS_CAP` for why no fee is computed on one.
|
||||
*/
|
||||
priceIsCap?: boolean | null;
|
||||
generationType: unknown;
|
||||
config?: BlockAuthorFeeConfig;
|
||||
}): Promise<BlockAuthorFeeObservation> {
|
||||
try {
|
||||
if (!(await isAppBlocksAuthorFeeEnabled())) return { observed: false, reason: 'flag-disabled' };
|
||||
} catch {
|
||||
// A flag read that will not resolve is not permission to charge anyone.
|
||||
return { observed: false, reason: 'flag-disabled' };
|
||||
}
|
||||
|
||||
// 🔴 A CAP-PRICED GENERATION IS SKIPPED, AND IT IS CHECKED BEFORE THE BASE.
|
||||
// The order is deliberate: a cap-priced generation that ALSO surfaced no base
|
||||
// must count as `price-is-cap`, not as `base-unavailable`. `base-unavailable`
|
||||
// is the RECOVERABLE blind spot — "the fee would have fired if the orchestrator
|
||||
// had given us a number" — and a cap-priced job would not have fired either
|
||||
// way, so folding it in would overstate exactly the population slice 2 sizes
|
||||
// its recovery work against. It IS counted (unlike `flag-disabled`, which is
|
||||
// deliberately silent) because how much traffic is cap-priced is a number
|
||||
// slice 2 needs; the `unknown` coarse label mirrors the `base-unavailable`
|
||||
// skip, which resolves no type either.
|
||||
if (args.priceIsCap === true) {
|
||||
try {
|
||||
blockAuthorFeeObservedCounter.inc({
|
||||
coarse_type: BLOCK_AUTHOR_FEE_UNKNOWN_TYPE_LABEL,
|
||||
outcome: BLOCK_AUTHOR_FEE_PRICE_IS_CAP,
|
||||
});
|
||||
} catch {
|
||||
// swallow — telemetry must never back-pressure the caller
|
||||
}
|
||||
return { observed: false, reason: BLOCK_AUTHOR_FEE_PRICE_IS_CAP };
|
||||
}
|
||||
|
||||
const base = args.baseGenerationBuzz;
|
||||
// 🔴 A MISSING BASE IS A SKIP, NOT A ZERO. Treating it as 0 would silently
|
||||
// pour "this generation was free" into the same bucket as a genuine zero-base
|
||||
// event and understate the fee slice 2 has to size. It gets its own counted
|
||||
// outcome so the blind spot is a number rather than an absence.
|
||||
if (typeof base !== 'number' || !Number.isFinite(base)) {
|
||||
try {
|
||||
blockAuthorFeeObservedCounter.inc({
|
||||
coarse_type: BLOCK_AUTHOR_FEE_UNKNOWN_TYPE_LABEL,
|
||||
outcome: BLOCK_AUTHOR_FEE_BASE_UNAVAILABLE,
|
||||
});
|
||||
} catch {
|
||||
// swallow — telemetry must never back-pressure the caller
|
||||
}
|
||||
return { observed: false, reason: BLOCK_AUTHOR_FEE_BASE_UNAVAILABLE };
|
||||
}
|
||||
|
||||
const computation = computeBlockAuthorFee({
|
||||
baseGenerationBuzz: base,
|
||||
generationType: args.generationType,
|
||||
config: args.config,
|
||||
});
|
||||
|
||||
const coarseLabel = computation.coarseType ?? BLOCK_AUTHOR_FEE_UNKNOWN_TYPE_LABEL;
|
||||
try {
|
||||
blockAuthorFeeObservedCounter.inc({
|
||||
coarse_type: coarseLabel,
|
||||
outcome: computation.governingLeg,
|
||||
});
|
||||
blockAuthorFeeBuzzCounter.inc({ coarse_type: coarseLabel }, computation.feeBuzz);
|
||||
blockAuthorFeeBaseBuzzCounter.inc({ coarse_type: coarseLabel }, computation.baseGenerationBuzz);
|
||||
} catch {
|
||||
// swallow — telemetry must never back-pressure the caller
|
||||
}
|
||||
|
||||
return { observed: true, computation };
|
||||
}
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
newBlockSpendAttributionId,
|
||||
newBlockSubscriptionAttributionId,
|
||||
} from '~/server/utils/app-block-ids';
|
||||
import { observeBlockAuthorFee } from './author-fee';
|
||||
import { isBlockGenerationType, type BlockGenerationType } from './generation-type';
|
||||
import {
|
||||
computeRateCardSplit,
|
||||
@@ -322,6 +323,49 @@ export type RecordSpendAttributionInput = {
|
||||
* money/audit row.
|
||||
*/
|
||||
generationType?: BlockGenerationType | null;
|
||||
/**
|
||||
* Optional BASE generation cost in Buzz — the orchestrator's
|
||||
* `WorkflowCost.base` for this workflow.
|
||||
*
|
||||
* 🔴 THIS IS NOT `buzzAmount`, AND THE DIFFERENCE IS THE WHOLE POINT OF THE
|
||||
* FIELD. `buzzAmount` above is the realized PAID DEBIT, a gross that already
|
||||
* carries the per-resource model LICENSING fees (`WorkflowCost.fees`), the
|
||||
* lineage fee and the viewer's tips — the orchestrator charges the sum and
|
||||
* settles each component to its own recipient. The per-generation AUTHOR FEE
|
||||
* is additive on top of the BASE and stacks alongside those components, so a
|
||||
* percentage of `buzzAmount` would take a cut of another creator's licensing
|
||||
* fee and of the viewer's tip, and would compound as more fee-charging
|
||||
* resources are stacked onto one generation.
|
||||
*
|
||||
* Nothing downstream can tell the two apart — both are plain positive Buzz
|
||||
* numbers — so the distinction has to be made by the CALLER, which reads
|
||||
* `cost.base` off the raw orchestrator submit response. It is not reachable
|
||||
* from the block-facing snapshot: `BlockWorkflowSnapshot.cost` is
|
||||
* deliberately `{ total }` only, and widening that wire shape would publish
|
||||
* the platform's cost breakdown to every third-party app.
|
||||
*
|
||||
* Used ONLY by the dark author-fee OBSERVATION below. It is never persisted:
|
||||
* omit it, or pass null, and the observation records a `base-unavailable` skip
|
||||
* instead of computing against a number that means something else.
|
||||
*/
|
||||
baseGenerationBuzz?: number | null;
|
||||
/**
|
||||
* Optional: the orchestrator's `WorkflowCost.variable` for this workflow —
|
||||
* TRUE when the quoted price is a CAP that may settle lower (at least one step
|
||||
* is post-billed and charged up front at its maximum, with the difference
|
||||
* refunded once the provider reports the actual work delivered).
|
||||
*
|
||||
* 🔴 A CAP-PRICED GENERATION IS OBSERVED AS A SKIP, NOT AS A FEE. A percentage
|
||||
* of a number the viewer will be partly refunded is a fee on money they did
|
||||
* not spend. It gets its OWN skip reason (`price-is-cap`) rather than being
|
||||
* folded into `base-unavailable` — see `BLOCK_AUTHOR_FEE_PRICE_IS_CAP`.
|
||||
*
|
||||
* Like `baseGenerationBuzz` this is read off the RAW orchestrator submit
|
||||
* response, never off `BlockWorkflowSnapshot` (whose `cost` is deliberately
|
||||
* `{ total }` only). Omit it, or pass null/false, and the price is treated as
|
||||
* final. Never persisted.
|
||||
*/
|
||||
generationPriceIsCap?: boolean | null;
|
||||
};
|
||||
|
||||
export type RecordSpendAttributionResult = {
|
||||
@@ -608,6 +652,80 @@ export async function recordSpendAttribution(
|
||||
},
|
||||
});
|
||||
|
||||
// PER-GENERATION AUTHOR FEE — DARK OBSERVATION ONLY (slice 1). Computes
|
||||
// what the additive, author-set, viewer-paid fee WOULD be for this
|
||||
// generation and reports it to the counters + the log line below. It moves
|
||||
// no money, writes no column, and is unreachable unless
|
||||
// `app-blocks-author-fee-enabled` is on. Settlement onto the licensing-fee
|
||||
// rail is a later slice; this exists so that slice can be sized from real
|
||||
// traffic before anyone is charged.
|
||||
//
|
||||
// 🔴 OBSERVED AFTER THE SUCCESSFUL WRITE, NOT BEFORE IT. This row is
|
||||
// idempotent on (workflowId, appBlockId); a re-poll / retry lands in the
|
||||
// P2002 branch below and must NOT observe a second fee for one generation,
|
||||
// or the sizing number is inflated by exactly the retry rate.
|
||||
//
|
||||
// 🔴 SELF-SPEND IS OBSERVED LIKE ANY OTHER GENERATION — deliberately, and
|
||||
// this is a DIVERGENCE from how attribution behaves two lines up, where
|
||||
// `isSelfSpend` voids the row and `computeSpendShare` zeroes the share. A
|
||||
// bounty is the platform paying an author out of platform money, so paying
|
||||
// an author for their own spend is a wash. The author fee is the VIEWER
|
||||
// paying the author, and an author using their own app is a viewer like any
|
||||
// other. ⚠️ FLAGGED FOR SLICE 2: at settlement that becomes a Buzz
|
||||
// transaction from an account to ITSELF, which is at best a no-op and may be
|
||||
// rejected outright. Slice 1's shape does not make that harder — the
|
||||
// observation carries no recipient, and `isSelfSpend` is already on this
|
||||
// log line beside the fee — but the settlement writer has to decide
|
||||
// explicitly whether a self-transfer is skipped or netted, rather than
|
||||
// discovering it from a rejected transaction.
|
||||
//
|
||||
// 🔴 NO `.catch` HERE, DELIBERATELY. `observeBlockAuthorFee` is TOTAL by
|
||||
// contract — every throwing surface inside it (the flag read, each counter
|
||||
// `inc`) is caught at its own site and degrades to a named skip. A
|
||||
// belt-and-braces `.catch(() => ({ reason: 'flag-disabled' }))` was written
|
||||
// here and REMOVED: it is unreachable given that contract, and were it ever
|
||||
// reachable it would file a THROW into the `flag-disabled` population —
|
||||
// which is one of the two denominators the slice-2 sizing read depends on.
|
||||
// A rejection here is a contract violation and must surface as one rather
|
||||
// than be laundered into a gate-is-off count.
|
||||
//
|
||||
// ⚠️ WHERE IT WOULD SURFACE — stated precisely, because an earlier revision
|
||||
// of this comment called the enclosing `catch` merely "loud" and that
|
||||
// UNDERSTATES IT BY THREE EFFECTS. A rejection here unwinds past everything
|
||||
// between this line and the `catch` below, for a row that WAS persisted:
|
||||
// 1. the success Axiom line is never written — the row exists with no
|
||||
// `block-spend-attribution` record of it;
|
||||
// 2. `blockSpendAttributionWriteCounter.inc({ status })` never fires, so
|
||||
// the written-row counter undercounts;
|
||||
// 3. the `catch` runs `refundAppBountyAccrual` against a row that was NOT
|
||||
// rolled back, double-releasing its reservation. Inert only while
|
||||
// `appOwnerShareCents` is identically 0 — i.e. until #2605.
|
||||
// Then it rethrows (not a P2002) and reaches the caller's fire-and-forget
|
||||
// `.catch`. That is still the correct destination for a broken contract —
|
||||
// `authorFee.reason` is not — but it is not a free "loud" either, so the
|
||||
// unreachability argument above is what carries this, and it holds for
|
||||
// today's one caller.
|
||||
//
|
||||
// 🔴 IF A `.catch` IS EVER REINSTATED it needs a NEW skip reason of its own
|
||||
// (`observe-failed`, say) — never ANY existing member of
|
||||
// `BlockAuthorFeeSkipReason`. Every reason in that union is a live
|
||||
// population the slice-2 sizing read divides by or reasons about, and
|
||||
// folding a contract violation into any of them is how a denominator
|
||||
// acquires a silent bias. Stated against the union rather than a list of
|
||||
// names on purpose: this comment previously said "a THIRD reason … never
|
||||
// `flag-disabled` and never `base-unavailable`", and went stale the moment
|
||||
// `price-is-cap` was added — it would now be the FOURTH, and the "never"
|
||||
// list had a hole in it exactly where the newest reason sat.
|
||||
const authorFee = await observeBlockAuthorFee({
|
||||
// 🔴 NOT `buzzAmount` — see the field docs on RecordSpendAttributionInput.
|
||||
baseGenerationBuzz: input.baseGenerationBuzz ?? null,
|
||||
// 🔴 A CAP PRICE SUPPRESSES THE FEE, under its own skip reason. Threaded
|
||||
// rather than inferred: nothing downstream of the orchestrator response
|
||||
// can tell a cap apart from a final price.
|
||||
priceIsCap: input.generationPriceIsCap ?? null,
|
||||
generationType,
|
||||
});
|
||||
|
||||
logToAxiom(
|
||||
{
|
||||
name: SPEND_ATTRIBUTION_LOG_NAME,
|
||||
@@ -635,6 +753,51 @@ export async function recordSpendAttribution(
|
||||
// appBountyDailyTotal=0). Surfaces a clamp the moment the cap bites.
|
||||
appBountyClamped: bountyReservation.clamped,
|
||||
appBountyDailyTotal: bountyReservation.total,
|
||||
// DARK author-fee observability — FOUR fields, and the set is chosen by
|
||||
// ONE rule: a property gets exactly one instrument, and this row is the
|
||||
// instrument only where the counters cannot reach. The counters carry a
|
||||
// single `coarse_type` label (deliberately — `appBlockId` would be
|
||||
// unbounded cardinality), so anything needing a per-APP, per-`isSelfSpend`
|
||||
// or per-full-`generationType` cut has to live here, beside those three
|
||||
// fields, which are already on this line.
|
||||
//
|
||||
// `authorFeeSkipped` the ONLY instrument for the flag-disabled
|
||||
// population — `observeBlockAuthorFee` emits no
|
||||
// counter at all on that path, by design, and it
|
||||
// is one of the two denominators the slice-2
|
||||
// sizing read divides by. Also encodes "observed":
|
||||
// null ⇔ the fee was computed.
|
||||
// `authorFeeBuzz` the fee, per row. The counter gives the total
|
||||
// by coarse type; only this gives "which apps
|
||||
// would earn what, and how much is self-spend".
|
||||
// `authorFeeBaseBuzz` its denominator, for the same per-app cut. Not
|
||||
// recoverable from `buzzAmount` above — that is
|
||||
// the gross, which already carries licensing
|
||||
// fees, the lineage fee and tips.
|
||||
// `authorFeeParamsSource` which level of the config answered. NO
|
||||
// counter carries it, and it is genuinely
|
||||
// variable in production now that
|
||||
// `BLOCK_AUTHOR_FEE_PLATFORM_CONFIG.byType` is
|
||||
// seeded (`chat-completion` → 'type', everything
|
||||
// else → 'default').
|
||||
//
|
||||
// DROPPED, and why — a field that cannot vary is not observability:
|
||||
// `authorFeeObserved` derivable: `authorFeeSkipped === null`.
|
||||
// `authorFeeLeg` exactly the `outcome` label of
|
||||
// `block_author_fee_observed_total`, and
|
||||
// re-derivable from fee + base + source.
|
||||
// `authorFeeParamsClamped` a COMPILE-TIME CONSTANT `false` in slice
|
||||
// 1 — re-derived after seeding `byType`, and
|
||||
// it is still constant: the only production
|
||||
// config is a module constant whose every
|
||||
// leg is inside the ceiling, and no caller
|
||||
// passes `config`. It becomes worth logging
|
||||
// in slice 3, when an author can type a
|
||||
// number; add it back then.
|
||||
authorFeeSkipped: authorFee.observed ? null : authorFee.reason,
|
||||
authorFeeBuzz: authorFee.observed ? authorFee.computation.feeBuzz : null,
|
||||
authorFeeBaseBuzz: authorFee.observed ? authorFee.computation.baseGenerationBuzz : null,
|
||||
authorFeeParamsSource: authorFee.observed ? authorFee.computation.source : null,
|
||||
},
|
||||
'webhooks'
|
||||
).catch(() => null);
|
||||
|
||||
Reference in New Issue
Block a user